@rubytech/create-maxy-code 0.1.576 → 0.1.578
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/active-rules/dist/index.js +32 -1
- package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
- package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
- package/payload/platform/lib/active-rules/src/index.ts +36 -2
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
- package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
- package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
- package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
- package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
- package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
- package/payload/platform/lib/routine-templates/dist/index.d.ts +4 -4
- package/payload/platform/lib/routine-templates/dist/index.js +4 -4
- package/payload/platform/lib/routine-templates/src/index.ts +4 -4
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +17 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
- package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
- package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
- package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
- package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/platform.md +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js +19 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js +110 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts +2 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js +22 -0
- package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/index.js +19 -163
- package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts +9 -0
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js +11 -0
- package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts +30 -3
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js +29 -4
- package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js.map +1 -1
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts +33 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts.map +1 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js +145 -0
- package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js.map +1 -0
- package/payload/platform/plugins/telegram/skills/configure/SKILL.md +4 -2
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/server/public/activity.html +4 -4
- package/payload/server/public/agents.html +3 -3
- package/payload/server/public/assets/{AdminLoginScreens-BLGZEaKU.js → AdminLoginScreens-CAZ3DSwA.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.gz +0 -0
- package/payload/server/public/assets/{AdminShell-C_M7YE5Z.js → AdminShell-BLs5v0qF.js} +1 -1
- package/payload/server/public/assets/AdminShell-BLs5v0qF.js.br +0 -0
- package/payload/server/public/assets/AdminShell-BLs5v0qF.js.gz +0 -0
- package/payload/server/public/assets/{activity-BL6qbB9a.js → activity-BwPck2yT.js} +1 -1
- package/payload/server/public/assets/activity-BwPck2yT.js.br +0 -0
- package/payload/server/public/assets/activity-BwPck2yT.js.gz +0 -0
- package/payload/server/public/assets/{admin-jnJ3P2u4.js → admin-DjtOoabA.js} +1 -1
- package/payload/server/public/assets/admin-DjtOoabA.js.br +0 -0
- package/payload/server/public/assets/admin-DjtOoabA.js.gz +0 -0
- package/payload/server/public/assets/{agents-BzhfKZDN.js → agents-IQYaRvoh.js} +1 -1
- package/payload/server/public/assets/agents-IQYaRvoh.js.br +0 -0
- package/payload/server/public/assets/agents-IQYaRvoh.js.gz +0 -0
- package/payload/server/public/assets/{browser-C-6io2B9.js → browser-CPG8w8rQ.js} +1 -1
- package/payload/server/public/assets/browser-CPG8w8rQ.js.br +0 -0
- package/payload/server/public/assets/browser-CPG8w8rQ.js.gz +0 -0
- package/payload/server/public/assets/{calendar-Dt3gS88s.js → calendar-CRwrgNdq.js} +1 -1
- package/payload/server/public/assets/calendar-CRwrgNdq.js.br +0 -0
- package/payload/server/public/assets/calendar-CRwrgNdq.js.gz +0 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js +1 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js.br +3 -0
- package/payload/server/public/assets/chat-B7yZ4KLl.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-BzKUQqLy.js +1 -0
- package/payload/server/public/assets/chevron-left-BzKUQqLy.js.br +0 -0
- package/payload/server/public/assets/clock-BafWFNGo.js +1 -0
- package/payload/server/public/assets/clock-BafWFNGo.js.br +0 -0
- package/payload/server/public/assets/{copy-TkPJgllS.js → copy-JyiMTV0k.js} +1 -1
- package/payload/server/public/assets/copy-JyiMTV0k.js.br +0 -0
- package/payload/server/public/assets/copy-JyiMTV0k.js.gz +0 -0
- package/payload/server/public/assets/data-DHtGCVrl.js +1 -0
- package/payload/server/public/assets/data-DHtGCVrl.js.br +0 -0
- package/payload/server/public/assets/data-DHtGCVrl.js.gz +0 -0
- package/payload/server/public/assets/{file-text-DyAL2DCh.js → file-text-cnHv9ZS6.js} +1 -1
- package/payload/server/public/assets/file-text-cnHv9ZS6.js.br +0 -0
- package/payload/server/public/assets/file-text-cnHv9ZS6.js.gz +0 -0
- package/payload/server/public/assets/{graph-PiNIcCfX.js → graph-lFwEUDSH.js} +1 -1
- package/payload/server/public/assets/graph-lFwEUDSH.js.br +0 -0
- package/payload/server/public/assets/graph-lFwEUDSH.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-8jKFrLHn.js → graph-labels-C6qUA0vO.js} +1 -1
- package/payload/server/public/assets/graph-labels-C6qUA0vO.js.br +0 -0
- package/payload/server/public/assets/graph-labels-C6qUA0vO.js.gz +0 -0
- package/payload/server/public/assets/{operator-DKGKGzrb.js → operator-BdoEy5mh.js} +1 -1
- package/payload/server/public/assets/operator-BdoEy5mh.js.br +0 -0
- package/payload/server/public/assets/operator-BdoEy5mh.js.gz +0 -0
- package/payload/server/public/assets/{page-DXz_K10G.js → page-DMrnnXNA.js} +1 -1
- package/payload/server/public/assets/page-DMrnnXNA.js.br +0 -0
- package/payload/server/public/assets/page-DMrnnXNA.js.gz +0 -0
- package/payload/server/public/assets/{page-BU5vlY9Y.js → page-DeMikGYL.js} +1 -1
- package/payload/server/public/assets/page-DeMikGYL.js.br +0 -0
- package/payload/server/public/assets/page-DeMikGYL.js.gz +0 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js +1 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js.br +0 -0
- package/payload/server/public/assets/play-D6OAQVJ_.js.gz +0 -0
- package/payload/server/public/assets/{public-BmA3-jLj.js → public-B-X1Cxfw.js} +1 -1
- package/payload/server/public/assets/public-B-X1Cxfw.js.br +0 -0
- package/payload/server/public/assets/public-B-X1Cxfw.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-fCH0LSMq.js → rotate-ccw-DVFvQAHG.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.gz +0 -0
- package/payload/server/public/assets/{routines-BeFrmkls.js → routines-CgkkGJaS.js} +1 -1
- package/payload/server/public/assets/routines-CgkkGJaS.js.br +0 -0
- package/payload/server/public/assets/routines-CgkkGJaS.js.gz +0 -0
- package/payload/server/public/assets/{skills-mjls5HBa.js → skills-CW7gVcaG.js} +1 -1
- package/payload/server/public/assets/skills-CW7gVcaG.js.br +0 -0
- package/payload/server/public/assets/skills-CW7gVcaG.js.gz +0 -0
- package/payload/server/public/assets/{tasks-p6puHHD0.js → tasks-Ddjp0Rwh.js} +1 -1
- package/payload/server/public/assets/tasks-Ddjp0Rwh.js.br +0 -0
- package/payload/server/public/assets/tasks-Ddjp0Rwh.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-CrZ1ewbd.js → triangle-alert-DUrUutX4.js} +1 -1
- package/payload/server/public/assets/triangle-alert-DUrUutX4.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DUrUutX4.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-zOQNrIwd.js → useCopyFeedback-ClVINDrQ.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.gz +0 -0
- package/payload/server/public/assets/{useMediaQuery-BmG090FR.js → useMediaQuery-BUFkZYRs.js} +1 -1
- package/payload/server/public/assets/useMediaQuery-BUFkZYRs.js.br +0 -0
- package/payload/server/public/assets/{useMediaQuery-BmG090FR.js.gz → useMediaQuery-BUFkZYRs.js.gz} +0 -0
- package/payload/server/public/assets/{useVoiceRecorder-9pf6FFJ-.js → useVoiceRecorder-DnyqQFet.js} +1 -1
- package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.gz +0 -0
- package/payload/server/public/assets/{wrench-k4GYG5rc.js → wrench-BW28s_d0.js} +1 -1
- package/payload/server/public/assets/wrench-BW28s_d0.js.br +0 -0
- package/payload/server/public/assets/wrench-BW28s_d0.js.gz +0 -0
- package/payload/server/public/browser.html +3 -3
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +10 -10
- package/payload/server/public/graph.html +8 -8
- package/payload/server/public/index.html +12 -12
- package/payload/server/public/operator.html +13 -13
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +4 -4
- package/payload/server/public/tasks.html +5 -5
- package/payload/server/server.js +1014 -375
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts +0 -6
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts.map +0 -1
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js +0 -102
- package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js +0 -87
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts +0 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js +0 -79
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts +0 -35
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js +0 -56
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts +0 -24
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts.map +0 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js +0 -87
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js.map +0 -1
- package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.br +0 -0
- package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.gz +0 -0
- package/payload/server/public/assets/activity-BL6qbB9a.js.br +0 -0
- package/payload/server/public/assets/activity-BL6qbB9a.js.gz +0 -0
- package/payload/server/public/assets/admin-jnJ3P2u4.js.br +0 -0
- package/payload/server/public/assets/admin-jnJ3P2u4.js.gz +0 -0
- package/payload/server/public/assets/agents-BzhfKZDN.js.br +0 -0
- package/payload/server/public/assets/agents-BzhfKZDN.js.gz +0 -0
- package/payload/server/public/assets/browser-C-6io2B9.js.br +0 -0
- package/payload/server/public/assets/browser-C-6io2B9.js.gz +0 -0
- package/payload/server/public/assets/calendar-Dt3gS88s.js.br +0 -0
- package/payload/server/public/assets/calendar-Dt3gS88s.js.gz +0 -0
- package/payload/server/public/assets/chat-IUOvVkL-.js +0 -1
- package/payload/server/public/assets/chat-IUOvVkL-.js.br +0 -1
- package/payload/server/public/assets/chat-IUOvVkL-.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DSzR9zR7.js +0 -1
- package/payload/server/public/assets/chevron-left-DSzR9zR7.js.br +0 -0
- package/payload/server/public/assets/clock-C5xE5p2I.js +0 -1
- package/payload/server/public/assets/clock-C5xE5p2I.js.br +0 -0
- package/payload/server/public/assets/copy-TkPJgllS.js.br +0 -0
- package/payload/server/public/assets/copy-TkPJgllS.js.gz +0 -0
- package/payload/server/public/assets/data-Bhv0Pvfl.js +0 -1
- package/payload/server/public/assets/data-Bhv0Pvfl.js.br +0 -0
- package/payload/server/public/assets/data-Bhv0Pvfl.js.gz +0 -0
- package/payload/server/public/assets/file-text-DyAL2DCh.js.br +0 -0
- package/payload/server/public/assets/file-text-DyAL2DCh.js.gz +0 -0
- package/payload/server/public/assets/graph-PiNIcCfX.js.br +0 -0
- package/payload/server/public/assets/graph-PiNIcCfX.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-8jKFrLHn.js.br +0 -0
- package/payload/server/public/assets/graph-labels-8jKFrLHn.js.gz +0 -0
- package/payload/server/public/assets/operator-DKGKGzrb.js.br +0 -0
- package/payload/server/public/assets/operator-DKGKGzrb.js.gz +0 -0
- package/payload/server/public/assets/page-BU5vlY9Y.js.br +0 -0
- package/payload/server/public/assets/page-BU5vlY9Y.js.gz +0 -0
- package/payload/server/public/assets/page-DXz_K10G.js.br +0 -0
- package/payload/server/public/assets/page-DXz_K10G.js.gz +0 -0
- package/payload/server/public/assets/play-CmSyYrB6.js +0 -1
- package/payload/server/public/assets/play-CmSyYrB6.js.br +0 -0
- package/payload/server/public/assets/play-CmSyYrB6.js.gz +0 -0
- package/payload/server/public/assets/public-BmA3-jLj.js.br +0 -0
- package/payload/server/public/assets/public-BmA3-jLj.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.gz +0 -0
- package/payload/server/public/assets/routines-BeFrmkls.js.br +0 -0
- package/payload/server/public/assets/routines-BeFrmkls.js.gz +0 -0
- package/payload/server/public/assets/skills-mjls5HBa.js.br +0 -0
- package/payload/server/public/assets/skills-mjls5HBa.js.gz +0 -0
- package/payload/server/public/assets/tasks-p6puHHD0.js.br +0 -0
- package/payload/server/public/assets/tasks-p6puHHD0.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.gz +0 -0
- package/payload/server/public/assets/useMediaQuery-BmG090FR.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.gz +0 -0
- package/payload/server/public/assets/wrench-k4GYG5rc.js.br +0 -0
- package/payload/server/public/assets/wrench-k4GYG5rc.js.gz +0 -0
|
@@ -9,8 +9,20 @@ interface Row {
|
|
|
9
9
|
accountId: string
|
|
10
10
|
ownerId: string
|
|
11
11
|
visitId: string
|
|
12
|
+
/** The Task 2511 record counts, when a fixture wants to prove they travel. */
|
|
13
|
+
notes?: number
|
|
14
|
+
media?: number
|
|
15
|
+
parts?: number
|
|
16
|
+
checklistDone?: number
|
|
17
|
+
checklistTotal?: number
|
|
18
|
+
signatures?: number
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
/** Every statement the endpoint prepared, so a test can assert on the column
|
|
22
|
+
* list rather than only on what a fixture happened to carry. A fake that only
|
|
23
|
+
* replays rows cannot tell a SELECT naming six columns from one naming none. */
|
|
24
|
+
const prepared: string[] = []
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* A fake that HONOURS the SQL it is handed rather than replaying a fixture.
|
|
16
28
|
*
|
|
@@ -30,18 +42,32 @@ interface RosterFixture {
|
|
|
30
42
|
interface MetaFixture {
|
|
31
43
|
vocabulary: string
|
|
32
44
|
statuses: string
|
|
45
|
+
transitions: string
|
|
33
46
|
zone: string
|
|
34
47
|
publishedAt: string
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
/** One published candidate row (Task 2511). `conflicts` is the JSON column. */
|
|
51
|
+
interface CandidateFixture {
|
|
52
|
+
accountId: string
|
|
53
|
+
visitId: string
|
|
54
|
+
workerId: string
|
|
55
|
+
name: string
|
|
56
|
+
kind: string
|
|
57
|
+
reason: string
|
|
58
|
+
conflicts: string
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
function db(
|
|
38
62
|
rows: Row[],
|
|
39
63
|
person: { ownerId: string; recordScope: string; accountId: string },
|
|
40
64
|
roster: RosterFixture[] = [],
|
|
41
65
|
meta: MetaFixture | null = null,
|
|
66
|
+
candidates: CandidateFixture[] = [],
|
|
42
67
|
) {
|
|
43
68
|
return {
|
|
44
69
|
prepare(sql: string) {
|
|
70
|
+
prepared.push(sql)
|
|
45
71
|
let bound: unknown[] = []
|
|
46
72
|
const stmt = {
|
|
47
73
|
bind(...v: unknown[]) {
|
|
@@ -58,6 +84,23 @@ function db(
|
|
|
58
84
|
return { meta: { changes: 0 } }
|
|
59
85
|
},
|
|
60
86
|
async all<T>() {
|
|
87
|
+
// visit_candidates FIRST: its statement also names `visits` in a JOIN,
|
|
88
|
+
// so testing for that table first would answer the candidate read with
|
|
89
|
+
// visit rows and the scope assertion below would pass on nothing.
|
|
90
|
+
if (sql.includes('FROM visit_candidates')) {
|
|
91
|
+
const [acct, owner] = bound as string[]
|
|
92
|
+
let out = candidates.filter((c) => c.accountId === acct)
|
|
93
|
+
if (sql.includes('v.ownerId = ?')) {
|
|
94
|
+
// The endpoint scopes candidates THROUGH the visits join, so the
|
|
95
|
+
// fake resolves the same way: a candidate is visible when its
|
|
96
|
+
// visit is.
|
|
97
|
+
const mine = new Set(
|
|
98
|
+
rows.filter((r) => r.accountId === acct && r.ownerId === owner).map((r) => r.visitId),
|
|
99
|
+
)
|
|
100
|
+
out = out.filter((c) => mine.has(c.visitId))
|
|
101
|
+
}
|
|
102
|
+
return { results: out as unknown as T[] }
|
|
103
|
+
}
|
|
61
104
|
if (sql.includes('FROM visit_roster')) {
|
|
62
105
|
const [acct, owner] = bound as string[]
|
|
63
106
|
let out = roster.filter((r) => r.accountId === acct)
|
|
@@ -161,10 +204,43 @@ const ROSTER: RosterFixture[] = [
|
|
|
161
204
|
const META: MetaFixture = {
|
|
162
205
|
vocabulary: '{"job":"job","visit":"visit","worker":"technician"}',
|
|
163
206
|
statuses: '[{"name":"new","terminal":false}]',
|
|
207
|
+
transitions: '[["new","done"]]',
|
|
164
208
|
zone: 'Europe/London',
|
|
165
209
|
publishedAt: '2026-08-07T12:00:00.000Z',
|
|
166
210
|
}
|
|
167
211
|
|
|
212
|
+
const CANDIDATES: CandidateFixture[] = [
|
|
213
|
+
// Against v1, which alice owns.
|
|
214
|
+
{
|
|
215
|
+
accountId: 'acc-a',
|
|
216
|
+
visitId: 'v1',
|
|
217
|
+
workerId: 'w1',
|
|
218
|
+
name: 'Alice Stone',
|
|
219
|
+
kind: 'offered',
|
|
220
|
+
reason: '',
|
|
221
|
+
conflicts: '[{"visitId":"v9","start":"a","end":"b"}]',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
accountId: 'acc-a',
|
|
225
|
+
visitId: 'v1',
|
|
226
|
+
workerId: 'w2',
|
|
227
|
+
name: 'Bob Reed',
|
|
228
|
+
kind: 'excluded',
|
|
229
|
+
reason: 'missing-qualification',
|
|
230
|
+
conflicts: '',
|
|
231
|
+
},
|
|
232
|
+
// Against v2, which bob owns.
|
|
233
|
+
{
|
|
234
|
+
accountId: 'acc-a',
|
|
235
|
+
visitId: 'v2',
|
|
236
|
+
workerId: 'w1',
|
|
237
|
+
name: 'Alice Stone',
|
|
238
|
+
kind: 'offered',
|
|
239
|
+
reason: '',
|
|
240
|
+
conflicts: '[]',
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
|
|
168
244
|
describe('the roster and the config come back through the same gate', () => {
|
|
169
245
|
it('gives an account-scoped person every worker on their own account', async () => {
|
|
170
246
|
const h = await processVisits(
|
|
@@ -239,3 +315,189 @@ describe('the roster and the config come back through the same gate', () => {
|
|
|
239
315
|
expect((h.payload.roster as Array<{ workingHours: unknown }>)[0].workingHours).toEqual({})
|
|
240
316
|
})
|
|
241
317
|
})
|
|
318
|
+
|
|
319
|
+
describe('the planner write half reads through the same gate (Task 2511)', () => {
|
|
320
|
+
const payload = (h: { payload: Record<string, unknown> }) => h.payload
|
|
321
|
+
|
|
322
|
+
it('returns the account transitions in meta', async () => {
|
|
323
|
+
const h = await processVisits(
|
|
324
|
+
'sess',
|
|
325
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
326
|
+
() => {},
|
|
327
|
+
1,
|
|
328
|
+
)
|
|
329
|
+
expect((payload(h).meta as { transitions: unknown }).transitions).toEqual([['new', 'done']])
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('returns an empty transition list when the column is malformed, never a throw', async () => {
|
|
333
|
+
// One bad value must not take a whole week off the screen. An empty list
|
|
334
|
+
// refuses every move, which is visibly wrong on screen; accepting every
|
|
335
|
+
// move would leave the page enforcing nothing and looking identical to a
|
|
336
|
+
// working one.
|
|
337
|
+
const broken = { ...META, transitions: 'not json' }
|
|
338
|
+
const h = await processVisits(
|
|
339
|
+
'sess',
|
|
340
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, broken, CANDIDATES)),
|
|
341
|
+
() => {},
|
|
342
|
+
1,
|
|
343
|
+
)
|
|
344
|
+
expect((payload(h).meta as { transitions: unknown }).transitions).toEqual([])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('returns the transitions to both scopes, because they are config not personal data', async () => {
|
|
348
|
+
for (const scope of ['own', 'account']) {
|
|
349
|
+
const h = await processVisits(
|
|
350
|
+
'sess',
|
|
351
|
+
env(db(ROWS, asPerson('alice', scope), ROSTER, META, CANDIDATES)),
|
|
352
|
+
() => {},
|
|
353
|
+
1,
|
|
354
|
+
)
|
|
355
|
+
expect((payload(h).meta as { transitions: unknown[] }).transitions).toHaveLength(1)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('gives an account-scoped person every candidate for the account', async () => {
|
|
360
|
+
const h = await processVisits(
|
|
361
|
+
'sess',
|
|
362
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
363
|
+
() => {},
|
|
364
|
+
1,
|
|
365
|
+
)
|
|
366
|
+
expect((payload(h).candidates as Array<{ workerId: string }>)).toHaveLength(3)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('scopes candidates to the visits the person may already see', async () => {
|
|
370
|
+
// A candidate row names a colleague. An own-scoped worker receiving
|
|
371
|
+
// candidates for a visit they cannot see would learn the roster the file
|
|
372
|
+
// drop deliberately withholds.
|
|
373
|
+
const h = await processVisits(
|
|
374
|
+
'sess',
|
|
375
|
+
env(db(ROWS, asPerson('alice', 'own'), ROSTER, META, CANDIDATES)),
|
|
376
|
+
() => {},
|
|
377
|
+
1,
|
|
378
|
+
)
|
|
379
|
+
const got = payload(h).candidates as Array<{ visitId: string }>
|
|
380
|
+
expect(got.map((c) => c.visitId)).toEqual(['v1', 'v1'])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('parses conflicts out of its JSON column so the page never parses it', async () => {
|
|
384
|
+
const h = await processVisits(
|
|
385
|
+
'sess',
|
|
386
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
387
|
+
() => {},
|
|
388
|
+
1,
|
|
389
|
+
)
|
|
390
|
+
const got = payload(h).candidates as Array<{
|
|
391
|
+
workerId: string
|
|
392
|
+
kind: string
|
|
393
|
+
conflicts: Array<{ visitId: string }>
|
|
394
|
+
}>
|
|
395
|
+
const offered = got.find((c) => c.kind === 'offered' && c.conflicts.length > 0)
|
|
396
|
+
expect(offered!.conflicts[0].visitId).toBe('v9')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('reads an excluded row empty conflicts column as an empty list, not a throw', async () => {
|
|
400
|
+
const h = await processVisits(
|
|
401
|
+
'sess',
|
|
402
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
403
|
+
() => {},
|
|
404
|
+
1,
|
|
405
|
+
)
|
|
406
|
+
const got = payload(h).candidates as Array<{ kind: string; conflicts: unknown }>
|
|
407
|
+
expect(got.find((c) => c.kind === 'excluded')!.conflicts).toEqual([])
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('answers with an empty candidate list when the account published none', async () => {
|
|
411
|
+
const h = await processVisits(
|
|
412
|
+
'sess',
|
|
413
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META)),
|
|
414
|
+
() => {},
|
|
415
|
+
1,
|
|
416
|
+
)
|
|
417
|
+
expect(payload(h).candidates).toEqual([])
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('logs a candidate COUNT and never a worker name', async () => {
|
|
421
|
+
const lines: string[] = []
|
|
422
|
+
await processVisits(
|
|
423
|
+
'sess',
|
|
424
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
425
|
+
(l) => lines.push(l),
|
|
426
|
+
1,
|
|
427
|
+
)
|
|
428
|
+
expect(lines.join('\n')).toMatch(/candidates=3/)
|
|
429
|
+
expect(lines.join('\n')).not.toContain('Alice Stone')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('names all six record counts in the visits SELECT', async () => {
|
|
433
|
+
// Asserted on the statement, not on the fixture: the fake ignores the
|
|
434
|
+
// column list, so a row carrying counts would come back whether or not the
|
|
435
|
+
// endpoint asked for them.
|
|
436
|
+
prepared.length = 0
|
|
437
|
+
await processVisits(
|
|
438
|
+
'sess',
|
|
439
|
+
env(db(ROWS, asPerson('alice', 'own'), ROSTER, META, CANDIDATES)),
|
|
440
|
+
() => {},
|
|
441
|
+
1,
|
|
442
|
+
)
|
|
443
|
+
const visitsSelect = prepared.find((s) => s.includes('FROM visits v'))
|
|
444
|
+
expect(visitsSelect).toBeDefined()
|
|
445
|
+
for (const field of [
|
|
446
|
+
'notes',
|
|
447
|
+
'media',
|
|
448
|
+
'parts',
|
|
449
|
+
'checklistDone',
|
|
450
|
+
'checklistTotal',
|
|
451
|
+
'signatures',
|
|
452
|
+
]) {
|
|
453
|
+
expect(visitsSelect!.includes(`v.${field} AS ${field}`), `SELECT names ${field}`).toBe(true)
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('passes the counts through to the caller untouched', async () => {
|
|
458
|
+
// The endpoint computes no count. Whatever the mirror holds is what the
|
|
459
|
+
// page draws, which is what makes the push the only place a count is made.
|
|
460
|
+
const counted: Row[] = [
|
|
461
|
+
{ accountId: 'acc-a', ownerId: 'alice', visitId: 'v1', parts: 3, checklistDone: 2, checklistTotal: 5 },
|
|
462
|
+
]
|
|
463
|
+
const h = await processVisits(
|
|
464
|
+
'sess',
|
|
465
|
+
env(db(counted, asPerson('alice', 'own'), ROSTER, META, CANDIDATES)),
|
|
466
|
+
() => {},
|
|
467
|
+
1,
|
|
468
|
+
)
|
|
469
|
+
const [visit] = payload(h).visits as Array<Record<string, unknown>>
|
|
470
|
+
expect(visit.parts).toBe(3)
|
|
471
|
+
expect(visit.checklistDone).toBe(2)
|
|
472
|
+
expect(visit.checklistTotal).toBe(5)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('scopes the candidate read through the visits join, not beside it', async () => {
|
|
476
|
+
// The owner filter must be the join's, or a second check would have to be
|
|
477
|
+
// kept in step with the visits read for ever.
|
|
478
|
+
prepared.length = 0
|
|
479
|
+
await processVisits(
|
|
480
|
+
'sess',
|
|
481
|
+
env(db(ROWS, asPerson('alice', 'own'), ROSTER, META, CANDIDATES)),
|
|
482
|
+
() => {},
|
|
483
|
+
1,
|
|
484
|
+
)
|
|
485
|
+
const candidateSelect = prepared.find((s) => s.includes('FROM visit_candidates'))
|
|
486
|
+
expect(candidateSelect).toBeDefined()
|
|
487
|
+
expect(candidateSelect!).toContain('JOIN visits v')
|
|
488
|
+
expect(candidateSelect!).toContain('v.generation = c.generation')
|
|
489
|
+
expect(candidateSelect!).toContain('v.ownerId = ?')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('drops the owner filter from the candidate read at account scope', async () => {
|
|
493
|
+
prepared.length = 0
|
|
494
|
+
await processVisits(
|
|
495
|
+
'sess',
|
|
496
|
+
env(db(ROWS, asPerson('marie', 'account'), ROSTER, META, CANDIDATES)),
|
|
497
|
+
() => {},
|
|
498
|
+
1,
|
|
499
|
+
)
|
|
500
|
+
const candidateSelect = prepared.find((s) => s.includes('FROM visit_candidates'))
|
|
501
|
+
expect(candidateSelect!).not.toContain('v.ownerId = ?')
|
|
502
|
+
})
|
|
503
|
+
})
|
|
@@ -253,10 +253,20 @@ CREATE TABLE IF NOT EXISTS directory_state (
|
|
|
253
253
|
--
|
|
254
254
|
-- READ THE ASYMMETRY WITH THE COLUMN NOTES ABOVE. Those warn that
|
|
255
255
|
-- IF NOT EXISTS adds no COLUMN to an existing table. It does create an absent
|
|
256
|
-
-- TABLE, so
|
|
257
|
-
-- Stated because a reader who has just absorbed the column warning would
|
|
256
|
+
-- TABLE, so a database that predates these tables gets them whole, with no hand
|
|
257
|
+
-- step. Stated because a reader who has just absorbed the column warning would
|
|
258
258
|
-- otherwise carry it across and go looking for a migration that is not needed.
|
|
259
259
|
--
|
|
260
|
+
-- The asymmetry bites the OTHER way for the six count columns Task 2511 added
|
|
261
|
+
-- to `visits` below. A database that already holds this table gets none of
|
|
262
|
+
-- them, so add them by hand in that case, in this order:
|
|
263
|
+
-- ALTER TABLE visits ADD COLUMN notes INTEGER NOT NULL DEFAULT 0;
|
|
264
|
+
-- ALTER TABLE visits ADD COLUMN media INTEGER NOT NULL DEFAULT 0;
|
|
265
|
+
-- ALTER TABLE visits ADD COLUMN parts INTEGER NOT NULL DEFAULT 0;
|
|
266
|
+
-- ALTER TABLE visits ADD COLUMN checklistDone INTEGER NOT NULL DEFAULT 0;
|
|
267
|
+
-- ALTER TABLE visits ADD COLUMN checklistTotal INTEGER NOT NULL DEFAULT 0;
|
|
268
|
+
-- ALTER TABLE visits ADD COLUMN signatures INTEGER NOT NULL DEFAULT 0;
|
|
269
|
+
--
|
|
260
270
|
-- `ownerId` is the enrolled portal person the visit is assigned to, resolved by
|
|
261
271
|
-- the push from an explicit `portalOwnerId` on the :Person carrying :Engineer.
|
|
262
272
|
-- It is '' when the engineer carries no such property. That row is NOT dropped:
|
|
@@ -269,16 +279,32 @@ CREATE TABLE IF NOT EXISTS directory_state (
|
|
|
269
279
|
-- in one forward-only statement, then sweeps what the pointer no longer names.
|
|
270
280
|
-- A listing that races a push reads the old set or the new one, never a
|
|
271
281
|
-- half-built one.
|
|
282
|
+
-- The six count columns are what the worker recorded, and they are COUNTS and
|
|
283
|
+
-- never contents (Task 2511). A note, a signature and a media reference
|
|
284
|
+
-- describe a real person's working day; this table's own header states that a
|
|
285
|
+
-- job sheet's contents are files the manifest already carries. The counts are
|
|
286
|
+
-- enough for the office to see what was recorded and what is still missing,
|
|
287
|
+
-- which is what it could not see at all before. The contents are Task 2563.
|
|
288
|
+
--
|
|
289
|
+
-- `checklistDone` against `checklistTotal` is the one pair read together: a
|
|
290
|
+
-- count of ticked items with no total says nothing about whether the work is
|
|
291
|
+
-- finished.
|
|
272
292
|
CREATE TABLE IF NOT EXISTS visits (
|
|
273
|
-
id
|
|
274
|
-
accountId
|
|
275
|
-
visitId
|
|
276
|
-
ownerId
|
|
277
|
-
jobId
|
|
278
|
-
status
|
|
279
|
-
startDate
|
|
280
|
-
purpose
|
|
281
|
-
|
|
293
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
294
|
+
accountId TEXT NOT NULL,
|
|
295
|
+
visitId TEXT NOT NULL,
|
|
296
|
+
ownerId TEXT NOT NULL DEFAULT '',
|
|
297
|
+
jobId TEXT NOT NULL DEFAULT '',
|
|
298
|
+
status TEXT NOT NULL DEFAULT '',
|
|
299
|
+
startDate TEXT NOT NULL DEFAULT '',
|
|
300
|
+
purpose TEXT NOT NULL DEFAULT '',
|
|
301
|
+
notes INTEGER NOT NULL DEFAULT 0,
|
|
302
|
+
media INTEGER NOT NULL DEFAULT 0,
|
|
303
|
+
parts INTEGER NOT NULL DEFAULT 0,
|
|
304
|
+
checklistDone INTEGER NOT NULL DEFAULT 0,
|
|
305
|
+
checklistTotal INTEGER NOT NULL DEFAULT 0,
|
|
306
|
+
signatures INTEGER NOT NULL DEFAULT 0,
|
|
307
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
282
308
|
UNIQUE (accountId, visitId, generation)
|
|
283
309
|
);
|
|
284
310
|
|
|
@@ -324,17 +350,40 @@ CREATE TABLE IF NOT EXISTS visits_state (
|
|
|
324
350
|
-- ALTER TABLE visit_status ADD COLUMN dayHundredths INTEGER NOT NULL DEFAULT 0;
|
|
325
351
|
-- ALTER TABLE visit_status ADD COLUMN workDate TEXT NOT NULL DEFAULT '';
|
|
326
352
|
-- ALTER TABLE visit_status ADD COLUMN workNote TEXT NOT NULL DEFAULT '';
|
|
353
|
+
-- ALTER TABLE visit_status ADD COLUMN assignedOwnerId TEXT NOT NULL DEFAULT '';
|
|
354
|
+
-- ALTER TABLE visit_status ADD COLUMN startDate TEXT NOT NULL DEFAULT '';
|
|
355
|
+
--
|
|
356
|
+
-- `assignedOwnerId` and `startDate` are the re-assignment and the re-timing an
|
|
357
|
+
-- office person made on the planner (Task 2511). They ride THIS table and the
|
|
358
|
+
-- existing door rather than a second write endpoint, so a status change and a
|
|
359
|
+
-- move reach the graph in one statement and cannot half-apply.
|
|
360
|
+
--
|
|
361
|
+
-- `assignedOwnerId` is the portal person the visit is being moved to, and ''
|
|
362
|
+
-- means this edit re-assigns nobody — the same absent-sentinel convention
|
|
363
|
+
-- `ownerId` uses above. The pull resolves it back to an engineer through
|
|
364
|
+
-- `:Engineer.portalOwnerId`, the property the push resolved forward. That
|
|
365
|
+
-- binding is not unique by construction, so an id naming zero or several
|
|
366
|
+
-- engineers writes nothing rather than picking one: a silent cross-assignment
|
|
367
|
+
-- shows one worker another's day.
|
|
368
|
+
--
|
|
369
|
+
-- `startDate` is the new start INSTANT, and '' means this edit re-times
|
|
370
|
+
-- nothing. An instant and not a date: `readCommitments` compares stored start
|
|
371
|
+
-- values lexically, so an offset-bearing or date-only value reads the wrong
|
|
372
|
+
-- window and a real double-booking goes unreported. The door refuses any other
|
|
373
|
+
-- shape rather than normalising one.
|
|
327
374
|
CREATE TABLE IF NOT EXISTS visit_status (
|
|
328
|
-
id
|
|
329
|
-
accountId
|
|
330
|
-
visitId
|
|
331
|
-
ownerId
|
|
332
|
-
status
|
|
333
|
-
statusAt
|
|
334
|
-
dayHundredths
|
|
335
|
-
workDate
|
|
336
|
-
workNote
|
|
337
|
-
|
|
375
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
376
|
+
accountId TEXT NOT NULL,
|
|
377
|
+
visitId TEXT NOT NULL,
|
|
378
|
+
ownerId TEXT NOT NULL,
|
|
379
|
+
status TEXT NOT NULL,
|
|
380
|
+
statusAt TEXT NOT NULL,
|
|
381
|
+
dayHundredths INTEGER NOT NULL DEFAULT 0,
|
|
382
|
+
workDate TEXT NOT NULL DEFAULT '',
|
|
383
|
+
workNote TEXT NOT NULL DEFAULT '',
|
|
384
|
+
assignedOwnerId TEXT NOT NULL DEFAULT '',
|
|
385
|
+
startDate TEXT NOT NULL DEFAULT '',
|
|
386
|
+
claimed INTEGER NOT NULL DEFAULT 0
|
|
338
387
|
);
|
|
339
388
|
|
|
340
389
|
-- The pull selects unclaimed rows for one account every sixty seconds, so the
|
|
@@ -394,12 +443,68 @@ CREATE INDEX IF NOT EXISTS visit_roster_account ON visit_roster (accountId);
|
|
|
394
443
|
-- `publishedAt` is when this generation was staged, so the page can state the
|
|
395
444
|
-- age of what it shows. A mirror that stopped refreshing is otherwise identical
|
|
396
445
|
-- to a week with no changes.
|
|
446
|
+
--
|
|
447
|
+
-- `transitions` is the account's own `visitTransitions` as JSON, `[[from, to]]`
|
|
448
|
+
-- (Task 2511). It rides the same generation as the statuses beside it, so the
|
|
449
|
+
-- moves a page offers and the statuses it draws can never be one flip apart.
|
|
450
|
+
-- Absent from the list means refused, on both sides: there is no state machine
|
|
451
|
+
-- in the code, and the door and the page each check this same published list.
|
|
452
|
+
-- The page's copy is what lets an illegal drag be refused while the install is
|
|
453
|
+
-- offline; the door's copy is what makes that refusal binding.
|
|
454
|
+
--
|
|
455
|
+
-- On a database that predates this column, add it by hand:
|
|
456
|
+
-- ALTER TABLE dispatch_meta ADD COLUMN transitions TEXT NOT NULL DEFAULT '';
|
|
397
457
|
CREATE TABLE IF NOT EXISTS dispatch_meta (
|
|
398
458
|
accountId TEXT NOT NULL,
|
|
399
459
|
vocabulary TEXT NOT NULL DEFAULT '',
|
|
400
460
|
statuses TEXT NOT NULL DEFAULT '',
|
|
461
|
+
transitions TEXT NOT NULL DEFAULT '',
|
|
401
462
|
zone TEXT NOT NULL DEFAULT '',
|
|
402
463
|
publishedAt TEXT NOT NULL DEFAULT '',
|
|
403
464
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
404
465
|
UNIQUE (accountId, generation)
|
|
405
466
|
);
|
|
467
|
+
|
|
468
|
+
-- Who may be sent to an unallocated visit, and why the others may not, per
|
|
469
|
+
-- staging generation (Task 2511).
|
|
470
|
+
--
|
|
471
|
+
-- Precomputed on the install by the one implementation of qualification and
|
|
472
|
+
-- clash (`platform/lib/dispatch-read/src/allocate.ts`) and never recomputed on
|
|
473
|
+
-- the page. `functions/api/visits.ts` states the rule: an account's own page
|
|
474
|
+
-- renders what that endpoint returns and never re-implements the filter,
|
|
475
|
+
-- because a gate written once per client is a gate got wrong once per client.
|
|
476
|
+
--
|
|
477
|
+
-- Rows exist for UNALLOCATED visits only, meaning a :Visit carrying no
|
|
478
|
+
-- attendance. A visit already staffed has nobody to offer.
|
|
479
|
+
--
|
|
480
|
+
-- `kind` decides which of the two following columns carries anything. An
|
|
481
|
+
-- `offered` row has `reason` empty and `conflicts` holding that worker's
|
|
482
|
+
-- commitments overlapping the window, which is '[]' when they are free — a
|
|
483
|
+
-- qualified worker already committed is offered WITH the conflict shown and
|
|
484
|
+
-- never dropped, because the office double-books on purpose and a surface that
|
|
485
|
+
-- removes the busy option makes that decision for them. An `excluded` row has
|
|
486
|
+
-- `conflicts` empty and `reason` naming the exclusion.
|
|
487
|
+
--
|
|
488
|
+
-- No travel estimate. Producing one costs a provider call per offered worker,
|
|
489
|
+
-- which on this path is one call per qualified worker per unallocated job every
|
|
490
|
+
-- sixty seconds, for as long as a job stays unallocated. The estimate stays in
|
|
491
|
+
-- `dispatch-allocate-candidates`, where the office asks about one job at a
|
|
492
|
+
-- time. Carrying it here is Task 2564.
|
|
493
|
+
--
|
|
494
|
+
-- Staged at the visits' generation and live through the same `visits_state`
|
|
495
|
+
-- flip, so a week and the candidates offered against it can never be one
|
|
496
|
+
-- generation apart.
|
|
497
|
+
CREATE TABLE IF NOT EXISTS visit_candidates (
|
|
498
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
499
|
+
accountId TEXT NOT NULL,
|
|
500
|
+
visitId TEXT NOT NULL,
|
|
501
|
+
workerId TEXT NOT NULL,
|
|
502
|
+
name TEXT NOT NULL DEFAULT '',
|
|
503
|
+
kind TEXT NOT NULL DEFAULT '',
|
|
504
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
505
|
+
conflicts TEXT NOT NULL DEFAULT '',
|
|
506
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
507
|
+
UNIQUE (accountId, visitId, workerId, generation)
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
CREATE INDEX IF NOT EXISTS visit_candidates_account ON visit_candidates (accountId);
|
|
@@ -33,6 +33,31 @@ export const MAX_NOTE_LENGTH = 500
|
|
|
33
33
|
*/
|
|
34
34
|
export const WORK_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The longest portal owner id a re-assignment may name.
|
|
38
|
+
*
|
|
39
|
+
* `assignedOwnerId` reaches the graph through the pull, where it is matched
|
|
40
|
+
* against `:Engineer.portalOwnerId`, so it is graph input arriving from the open
|
|
41
|
+
* web and gets the treatment the status gets: refused at the door, never
|
|
42
|
+
* truncated. A truncated id matches a different person, or nobody, silently,
|
|
43
|
+
* and the person who dragged the card is never told.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_OWNER_ID_LENGTH = 64
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The only instant shape a re-timing may carry.
|
|
49
|
+
*
|
|
50
|
+
* `new Date().toISOString()`'s own output, which is what every canonical instant
|
|
51
|
+
* in this exchange already stores. An offset-bearing value is REFUSED rather
|
|
52
|
+
* than converted: `readCommitments` compares these values LEXICALLY against the
|
|
53
|
+
* stored start, so an offset-bearing row reads the wrong window and a real
|
|
54
|
+
* double-booking goes unreported. A COPY of this check lives in
|
|
55
|
+
* `platform/ui/server/portal-visits-pull.ts` as the invariant at the writer,
|
|
56
|
+
* for the reason WORK_DATE_RE gives above: this file ships to Cloudflare Pages
|
|
57
|
+
* and cannot import from the platform tree.
|
|
58
|
+
*/
|
|
59
|
+
export const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
60
|
+
|
|
36
61
|
/**
|
|
37
62
|
* Record a status a person set against one of their visits.
|
|
38
63
|
*
|
|
@@ -103,13 +128,115 @@ export async function processVisitStatus(
|
|
|
103
128
|
}
|
|
104
129
|
if (workNote.length > MAX_NOTE_LENGTH) {
|
|
105
130
|
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=note-too-long`)
|
|
131
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=note-too-long`)
|
|
106
132
|
return { status: 400, payload: { ok: false, error: 'note too long' } }
|
|
107
133
|
}
|
|
108
134
|
|
|
135
|
+
// The move this edit carries, if any (Task 2511). Both absent means a
|
|
136
|
+
// status-only edit, which is the common case and stays a zero-move write.
|
|
137
|
+
const assignedOwnerId = typeof b.assignedOwnerId === 'string' ? b.assignedOwnerId : ''
|
|
138
|
+
const startDate = typeof b.startDate === 'string' ? b.startDate : ''
|
|
139
|
+
if (assignedOwnerId.length > MAX_OWNER_ID_LENGTH) {
|
|
140
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=owner-too-long`)
|
|
141
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=owner-too-long`)
|
|
142
|
+
return { status: 400, payload: { ok: false, error: 'assignedOwnerId too long' } }
|
|
143
|
+
}
|
|
144
|
+
if (startDate !== '' && !INSTANT_RE.test(startDate)) {
|
|
145
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=bad-instant`)
|
|
146
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=bad-instant`)
|
|
147
|
+
return {
|
|
148
|
+
status: 400,
|
|
149
|
+
payload: { ok: false, error: 'startDate must be an instant as YYYY-MM-DDTHH:MM:SS.sssZ' },
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A move and a labour claim in one edit is refused, not merged. The device
|
|
153
|
+
// applies a move in one statement that writes no cost, so a row carrying both
|
|
154
|
+
// would set the status, move the worker, be claimed, and drop the day the
|
|
155
|
+
// worker stated — silently, with nothing left to retry against. No shipped
|
|
156
|
+
// surface sends both; this door is on the open web and admits any body, which
|
|
157
|
+
// is exactly why the pair is named and refused rather than assumed absent.
|
|
158
|
+
if ((assignedOwnerId !== '' || startDate !== '') && dayHundredths > 0) {
|
|
159
|
+
log(`[data-portal] op=visit-status owner=${q(session.ownerId)} result=move-with-labour`)
|
|
160
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=move-with-labour`)
|
|
161
|
+
return {
|
|
162
|
+
status: 400,
|
|
163
|
+
payload: { ok: false, error: 'a move and a day claim must be sent as separate edits' },
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
109
167
|
const scoped = session.recordScope === 'own'
|
|
168
|
+
|
|
169
|
+
// The visit's CURRENT status and the account's declared moves, read under THE
|
|
170
|
+
// SAME TWO FILTERS the insert applies (Task 2511).
|
|
171
|
+
//
|
|
172
|
+
// THIS IS A REFUSAL, NOT THE GATE. The insert's own SELECT still decides what
|
|
173
|
+
// may be written, so nothing added here can be reordered past the write, and
|
|
174
|
+
// the property this file states at `processVisitStatus` is unchanged.
|
|
175
|
+
//
|
|
176
|
+
// The refusal is synchronous and it is here. The device-side pull runs sixty
|
|
177
|
+
// seconds later with no screen to refuse to, and `buildVisitStatusWrite` sets
|
|
178
|
+
// the status unconditionally, so an illegal move accepted here reaches the
|
|
179
|
+
// graph with nothing saying so.
|
|
180
|
+
const currentSql =
|
|
181
|
+
`SELECT v.status AS status, m.transitions AS transitions
|
|
182
|
+
FROM visits v
|
|
183
|
+
JOIN visits_state s
|
|
184
|
+
ON s.accountId = v.accountId AND s.currentGeneration = v.generation
|
|
185
|
+
LEFT JOIN dispatch_meta m
|
|
186
|
+
ON m.accountId = v.accountId AND m.generation = v.generation
|
|
187
|
+
WHERE v.accountId = ?` +
|
|
188
|
+
(scoped ? ' AND v.ownerId = ?' : '') +
|
|
189
|
+
' AND v.visitId = ?'
|
|
190
|
+
const currentStmt = env.DB.prepare(currentSql)
|
|
191
|
+
const current = await (
|
|
192
|
+
scoped
|
|
193
|
+
? currentStmt.bind(env.PORTAL_ACCOUNT_ID, session.ownerId, visitId)
|
|
194
|
+
: currentStmt.bind(env.PORTAL_ACCOUNT_ID, visitId)
|
|
195
|
+
).first<{ status: string; transitions: string }>()
|
|
196
|
+
|
|
197
|
+
if (!current) {
|
|
198
|
+
// The SAME single answer the insert gives below, and for the same reason:
|
|
199
|
+
// "no such visit" and "not yours" are one fact to a caller who may not see
|
|
200
|
+
// it, and distinguishing them would let anyone enumerate the account's
|
|
201
|
+
// visit ids.
|
|
202
|
+
log(
|
|
203
|
+
`[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
204
|
+
`scope=${session.recordScope} result=not-found`,
|
|
205
|
+
)
|
|
206
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=unknown-visit`)
|
|
207
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A write whose status equals the current one is NOT A MOVE. `[from, to]`
|
|
211
|
+
// declares a move, and no account declares a self-transition, so without this
|
|
212
|
+
// a pure re-timing would be refused for failing to be a transition — and this
|
|
213
|
+
// endpoint requires a status on every request.
|
|
214
|
+
if (status !== current.status) {
|
|
215
|
+
let legal: unknown = []
|
|
216
|
+
try {
|
|
217
|
+
legal = JSON.parse(current.transitions || '[]')
|
|
218
|
+
} catch {
|
|
219
|
+
// A malformed list refuses every move rather than allowing every move.
|
|
220
|
+
// Refusing is visible on screen; allowing leaves a page that enforces
|
|
221
|
+
// nothing and is indistinguishable from one that enforces correctly.
|
|
222
|
+
legal = []
|
|
223
|
+
}
|
|
224
|
+
const allowed =
|
|
225
|
+
Array.isArray(legal) &&
|
|
226
|
+
legal.some((t) => Array.isArray(t) && t[0] === current.status && t[1] === status)
|
|
227
|
+
if (!allowed) {
|
|
228
|
+
log(
|
|
229
|
+
`[data-portal] op=visit-status owner=${q(session.ownerId)} ` +
|
|
230
|
+
`scope=${session.recordScope} result=illegal-transition`,
|
|
231
|
+
)
|
|
232
|
+
log(`[data-portal] op=rejected visitId=${q(visitId)} reason=illegal-transition`)
|
|
233
|
+
return { status: 400, payload: { ok: false, error: 'illegal-transition' } }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
110
237
|
const sql =
|
|
111
|
-
`INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, claimed, visitId)
|
|
112
|
-
SELECT ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
|
|
238
|
+
`INSERT INTO visit_status (accountId, ownerId, status, statusAt, dayHundredths, workDate, workNote, assignedOwnerId, startDate, claimed, visitId)
|
|
239
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, v.visitId
|
|
113
240
|
FROM visits v
|
|
114
241
|
JOIN visits_state s
|
|
115
242
|
ON s.accountId = v.accountId AND s.currentGeneration = v.generation
|
|
@@ -127,6 +254,8 @@ export async function processVisitStatus(
|
|
|
127
254
|
dayHundredths,
|
|
128
255
|
workDate,
|
|
129
256
|
workNote,
|
|
257
|
+
assignedOwnerId,
|
|
258
|
+
startDate,
|
|
130
259
|
env.PORTAL_ACCOUNT_ID,
|
|
131
260
|
session.ownerId,
|
|
132
261
|
visitId,
|
|
@@ -139,6 +268,8 @@ export async function processVisitStatus(
|
|
|
139
268
|
dayHundredths,
|
|
140
269
|
workDate,
|
|
141
270
|
workNote,
|
|
271
|
+
assignedOwnerId,
|
|
272
|
+
startDate,
|
|
142
273
|
env.PORTAL_ACCOUNT_ID,
|
|
143
274
|
visitId,
|
|
144
275
|
)
|
|
@@ -159,6 +290,15 @@ export async function processVisitStatus(
|
|
|
159
290
|
// Counts, the scope, whether a day was claimed, and the outcome. Never the
|
|
160
291
|
// status text, the date or the note: they describe what a real person was
|
|
161
292
|
// doing and when.
|
|
293
|
+
//
|
|
294
|
+
// THE `op=rejected` LINES ABOVE DO CARRY THE visitId, which this file
|
|
295
|
+
// otherwise omits (Task 2511, operator decision 2026-08-08). The failure they
|
|
296
|
+
// exist to catch is a write this door accepted that the pull never applied,
|
|
297
|
+
// which leaves the page showing a change the graph does not have; detecting
|
|
298
|
+
// it means pairing a refusal or an acceptance here to one visit in
|
|
299
|
+
// `[portal-visits-pull] op=applied`, and no other field the two logs share
|
|
300
|
+
// identifies one. The exception is those lines alone: this line still carries
|
|
301
|
+
// counts and no visit.
|
|
162
302
|
log(
|
|
163
303
|
`[data-portal] op=visit-status owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
164
304
|
`scope=${session.recordScope} labour=${dayHundredths > 0} queued=${changes} result=ok`,
|