@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.
Files changed (259) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/active-rules/dist/index.js +32 -1
  4. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  5. package/payload/platform/lib/active-rules/src/index.test.ts +52 -3
  6. package/payload/platform/lib/active-rules/src/index.ts +36 -2
  7. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts +57 -0
  8. package/payload/platform/lib/dispatch-read/dist/allocate.d.ts.map +1 -0
  9. package/payload/platform/lib/dispatch-read/dist/allocate.js +33 -0
  10. package/payload/platform/lib/dispatch-read/dist/allocate.js.map +1 -0
  11. package/payload/platform/lib/dispatch-read/dist/index.d.ts +2 -0
  12. package/payload/platform/lib/dispatch-read/dist/index.d.ts.map +1 -1
  13. package/payload/platform/lib/dispatch-read/dist/index.js +10 -1
  14. package/payload/platform/lib/dispatch-read/dist/index.js.map +1 -1
  15. package/payload/platform/lib/dispatch-read/src/__tests__/allocate.test.ts +41 -0
  16. package/payload/platform/lib/dispatch-read/src/allocate.ts +89 -0
  17. package/payload/platform/lib/dispatch-read/src/index.ts +14 -0
  18. package/payload/platform/lib/routine-templates/dist/index.d.ts +4 -4
  19. package/payload/platform/lib/routine-templates/dist/index.js +4 -4
  20. package/payload/platform/lib/routine-templates/src/index.ts +4 -4
  21. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +7 -2
  22. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +8 -2
  23. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +4 -2
  24. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +17 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visit-status-write.test.ts +333 -22
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-visits-scope.test.ts +262 -0
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +126 -21
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visit-status.ts +142 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/visits.ts +118 -6
  30. package/payload/platform/plugins/dispatch/PLUGIN.md +2 -0
  31. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts +19 -50
  32. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.d.ts.map +1 -1
  33. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js +1 -28
  34. package/payload/platform/plugins/dispatch/mcp/dist/lib/allocate.js.map +1 -1
  35. package/payload/platform/plugins/dispatch/skills/dispatch-page/SKILL.md +45 -6
  36. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.css +60 -0
  37. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.html +13 -0
  38. package/payload/platform/plugins/dispatch/skills/dispatch-page/template/planner.js +203 -5
  39. package/payload/platform/plugins/docs/references/internals.md +1 -1
  40. package/payload/platform/plugins/docs/references/platform.md +2 -0
  41. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +0 -2
  42. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  43. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts +2 -0
  44. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js +19 -0
  46. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-path.test.js.map +1 -0
  47. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts +2 -0
  48. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js +110 -0
  50. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-register.test.js.map +1 -0
  51. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts +2 -0
  52. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.d.ts.map +1 -0
  53. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js +22 -0
  54. package/payload/platform/plugins/telegram/mcp/dist/__tests__/webhook-verdict.test.js.map +1 -0
  55. package/payload/platform/plugins/telegram/mcp/dist/index.js +19 -163
  56. package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
  57. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts +9 -0
  58. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.d.ts.map +1 -1
  59. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js +11 -0
  60. package/payload/platform/plugins/telegram/mcp/dist/lib/account-write.js.map +1 -1
  61. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts +30 -3
  62. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.d.ts.map +1 -1
  63. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js +29 -4
  64. package/payload/platform/plugins/telegram/mcp/dist/lib/telegram.js.map +1 -1
  65. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts +33 -0
  66. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.d.ts.map +1 -0
  67. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js +145 -0
  68. package/payload/platform/plugins/telegram/mcp/dist/tools/webhook-register.js.map +1 -0
  69. package/payload/platform/plugins/telegram/skills/configure/SKILL.md +4 -2
  70. package/payload/platform/services/telegram-channel/dist/notification.d.ts +35 -3
  71. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  72. package/payload/platform/services/telegram-channel/dist/notification.js +38 -5
  73. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  74. package/payload/platform/services/telegram-channel/dist/server.js +1 -1
  75. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  76. package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
  77. package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
  78. package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
  79. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +2 -2
  80. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  81. package/payload/server/public/activity.html +4 -4
  82. package/payload/server/public/agents.html +3 -3
  83. package/payload/server/public/assets/{AdminLoginScreens-BLGZEaKU.js → AdminLoginScreens-CAZ3DSwA.js} +1 -1
  84. package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.br +0 -0
  85. package/payload/server/public/assets/AdminLoginScreens-CAZ3DSwA.js.gz +0 -0
  86. package/payload/server/public/assets/{AdminShell-C_M7YE5Z.js → AdminShell-BLs5v0qF.js} +1 -1
  87. package/payload/server/public/assets/AdminShell-BLs5v0qF.js.br +0 -0
  88. package/payload/server/public/assets/AdminShell-BLs5v0qF.js.gz +0 -0
  89. package/payload/server/public/assets/{activity-BL6qbB9a.js → activity-BwPck2yT.js} +1 -1
  90. package/payload/server/public/assets/activity-BwPck2yT.js.br +0 -0
  91. package/payload/server/public/assets/activity-BwPck2yT.js.gz +0 -0
  92. package/payload/server/public/assets/{admin-jnJ3P2u4.js → admin-DjtOoabA.js} +1 -1
  93. package/payload/server/public/assets/admin-DjtOoabA.js.br +0 -0
  94. package/payload/server/public/assets/admin-DjtOoabA.js.gz +0 -0
  95. package/payload/server/public/assets/{agents-BzhfKZDN.js → agents-IQYaRvoh.js} +1 -1
  96. package/payload/server/public/assets/agents-IQYaRvoh.js.br +0 -0
  97. package/payload/server/public/assets/agents-IQYaRvoh.js.gz +0 -0
  98. package/payload/server/public/assets/{browser-C-6io2B9.js → browser-CPG8w8rQ.js} +1 -1
  99. package/payload/server/public/assets/browser-CPG8w8rQ.js.br +0 -0
  100. package/payload/server/public/assets/browser-CPG8w8rQ.js.gz +0 -0
  101. package/payload/server/public/assets/{calendar-Dt3gS88s.js → calendar-CRwrgNdq.js} +1 -1
  102. package/payload/server/public/assets/calendar-CRwrgNdq.js.br +0 -0
  103. package/payload/server/public/assets/calendar-CRwrgNdq.js.gz +0 -0
  104. package/payload/server/public/assets/chat-B7yZ4KLl.js +1 -0
  105. package/payload/server/public/assets/chat-B7yZ4KLl.js.br +3 -0
  106. package/payload/server/public/assets/chat-B7yZ4KLl.js.gz +0 -0
  107. package/payload/server/public/assets/chevron-left-BzKUQqLy.js +1 -0
  108. package/payload/server/public/assets/chevron-left-BzKUQqLy.js.br +0 -0
  109. package/payload/server/public/assets/clock-BafWFNGo.js +1 -0
  110. package/payload/server/public/assets/clock-BafWFNGo.js.br +0 -0
  111. package/payload/server/public/assets/{copy-TkPJgllS.js → copy-JyiMTV0k.js} +1 -1
  112. package/payload/server/public/assets/copy-JyiMTV0k.js.br +0 -0
  113. package/payload/server/public/assets/copy-JyiMTV0k.js.gz +0 -0
  114. package/payload/server/public/assets/data-DHtGCVrl.js +1 -0
  115. package/payload/server/public/assets/data-DHtGCVrl.js.br +0 -0
  116. package/payload/server/public/assets/data-DHtGCVrl.js.gz +0 -0
  117. package/payload/server/public/assets/{file-text-DyAL2DCh.js → file-text-cnHv9ZS6.js} +1 -1
  118. package/payload/server/public/assets/file-text-cnHv9ZS6.js.br +0 -0
  119. package/payload/server/public/assets/file-text-cnHv9ZS6.js.gz +0 -0
  120. package/payload/server/public/assets/{graph-PiNIcCfX.js → graph-lFwEUDSH.js} +1 -1
  121. package/payload/server/public/assets/graph-lFwEUDSH.js.br +0 -0
  122. package/payload/server/public/assets/graph-lFwEUDSH.js.gz +0 -0
  123. package/payload/server/public/assets/{graph-labels-8jKFrLHn.js → graph-labels-C6qUA0vO.js} +1 -1
  124. package/payload/server/public/assets/graph-labels-C6qUA0vO.js.br +0 -0
  125. package/payload/server/public/assets/graph-labels-C6qUA0vO.js.gz +0 -0
  126. package/payload/server/public/assets/{operator-DKGKGzrb.js → operator-BdoEy5mh.js} +1 -1
  127. package/payload/server/public/assets/operator-BdoEy5mh.js.br +0 -0
  128. package/payload/server/public/assets/operator-BdoEy5mh.js.gz +0 -0
  129. package/payload/server/public/assets/{page-DXz_K10G.js → page-DMrnnXNA.js} +1 -1
  130. package/payload/server/public/assets/page-DMrnnXNA.js.br +0 -0
  131. package/payload/server/public/assets/page-DMrnnXNA.js.gz +0 -0
  132. package/payload/server/public/assets/{page-BU5vlY9Y.js → page-DeMikGYL.js} +1 -1
  133. package/payload/server/public/assets/page-DeMikGYL.js.br +0 -0
  134. package/payload/server/public/assets/page-DeMikGYL.js.gz +0 -0
  135. package/payload/server/public/assets/play-D6OAQVJ_.js +1 -0
  136. package/payload/server/public/assets/play-D6OAQVJ_.js.br +0 -0
  137. package/payload/server/public/assets/play-D6OAQVJ_.js.gz +0 -0
  138. package/payload/server/public/assets/{public-BmA3-jLj.js → public-B-X1Cxfw.js} +1 -1
  139. package/payload/server/public/assets/public-B-X1Cxfw.js.br +0 -0
  140. package/payload/server/public/assets/public-B-X1Cxfw.js.gz +0 -0
  141. package/payload/server/public/assets/{rotate-ccw-fCH0LSMq.js → rotate-ccw-DVFvQAHG.js} +1 -1
  142. package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.br +0 -0
  143. package/payload/server/public/assets/rotate-ccw-DVFvQAHG.js.gz +0 -0
  144. package/payload/server/public/assets/{routines-BeFrmkls.js → routines-CgkkGJaS.js} +1 -1
  145. package/payload/server/public/assets/routines-CgkkGJaS.js.br +0 -0
  146. package/payload/server/public/assets/routines-CgkkGJaS.js.gz +0 -0
  147. package/payload/server/public/assets/{skills-mjls5HBa.js → skills-CW7gVcaG.js} +1 -1
  148. package/payload/server/public/assets/skills-CW7gVcaG.js.br +0 -0
  149. package/payload/server/public/assets/skills-CW7gVcaG.js.gz +0 -0
  150. package/payload/server/public/assets/{tasks-p6puHHD0.js → tasks-Ddjp0Rwh.js} +1 -1
  151. package/payload/server/public/assets/tasks-Ddjp0Rwh.js.br +0 -0
  152. package/payload/server/public/assets/tasks-Ddjp0Rwh.js.gz +0 -0
  153. package/payload/server/public/assets/{triangle-alert-CrZ1ewbd.js → triangle-alert-DUrUutX4.js} +1 -1
  154. package/payload/server/public/assets/triangle-alert-DUrUutX4.js.br +0 -0
  155. package/payload/server/public/assets/triangle-alert-DUrUutX4.js.gz +0 -0
  156. package/payload/server/public/assets/{useCopyFeedback-zOQNrIwd.js → useCopyFeedback-ClVINDrQ.js} +1 -1
  157. package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.br +0 -0
  158. package/payload/server/public/assets/useCopyFeedback-ClVINDrQ.js.gz +0 -0
  159. package/payload/server/public/assets/{useMediaQuery-BmG090FR.js → useMediaQuery-BUFkZYRs.js} +1 -1
  160. package/payload/server/public/assets/useMediaQuery-BUFkZYRs.js.br +0 -0
  161. package/payload/server/public/assets/{useMediaQuery-BmG090FR.js.gz → useMediaQuery-BUFkZYRs.js.gz} +0 -0
  162. package/payload/server/public/assets/{useVoiceRecorder-9pf6FFJ-.js → useVoiceRecorder-DnyqQFet.js} +1 -1
  163. package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.br +0 -0
  164. package/payload/server/public/assets/useVoiceRecorder-DnyqQFet.js.gz +0 -0
  165. package/payload/server/public/assets/{wrench-k4GYG5rc.js → wrench-BW28s_d0.js} +1 -1
  166. package/payload/server/public/assets/wrench-BW28s_d0.js.br +0 -0
  167. package/payload/server/public/assets/wrench-BW28s_d0.js.gz +0 -0
  168. package/payload/server/public/browser.html +3 -3
  169. package/payload/server/public/calendar.html +6 -6
  170. package/payload/server/public/chat.html +12 -12
  171. package/payload/server/public/data.html +10 -10
  172. package/payload/server/public/graph.html +8 -8
  173. package/payload/server/public/index.html +12 -12
  174. package/payload/server/public/operator.html +13 -13
  175. package/payload/server/public/public.html +12 -12
  176. package/payload/server/public/routines.html +6 -6
  177. package/payload/server/public/skills.html +4 -4
  178. package/payload/server/public/tasks.html +5 -5
  179. package/payload/server/server.js +1014 -375
  180. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts +0 -6
  181. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.d.ts.map +0 -1
  182. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js +0 -102
  183. package/payload/platform/plugins/dispatch/mcp/dist/scripts/converge-visit-instants.js.map +0 -1
  184. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts +0 -2
  185. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.d.ts.map +0 -1
  186. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js +0 -87
  187. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-routine-description.test.js.map +0 -1
  188. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts +0 -2
  189. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.d.ts.map +0 -1
  190. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js +0 -79
  191. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/converge-startdate-utc.test.js.map +0 -1
  192. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts +0 -35
  193. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.d.ts.map +0 -1
  194. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js +0 -56
  195. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-routine-description.js.map +0 -1
  196. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts +0 -24
  197. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.d.ts.map +0 -1
  198. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js +0 -87
  199. package/payload/platform/plugins/scheduling/mcp/dist/scripts/converge-startdate-utc.js.map +0 -1
  200. package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.br +0 -0
  201. package/payload/server/public/assets/AdminLoginScreens-BLGZEaKU.js.gz +0 -0
  202. package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.br +0 -0
  203. package/payload/server/public/assets/AdminShell-C_M7YE5Z.js.gz +0 -0
  204. package/payload/server/public/assets/activity-BL6qbB9a.js.br +0 -0
  205. package/payload/server/public/assets/activity-BL6qbB9a.js.gz +0 -0
  206. package/payload/server/public/assets/admin-jnJ3P2u4.js.br +0 -0
  207. package/payload/server/public/assets/admin-jnJ3P2u4.js.gz +0 -0
  208. package/payload/server/public/assets/agents-BzhfKZDN.js.br +0 -0
  209. package/payload/server/public/assets/agents-BzhfKZDN.js.gz +0 -0
  210. package/payload/server/public/assets/browser-C-6io2B9.js.br +0 -0
  211. package/payload/server/public/assets/browser-C-6io2B9.js.gz +0 -0
  212. package/payload/server/public/assets/calendar-Dt3gS88s.js.br +0 -0
  213. package/payload/server/public/assets/calendar-Dt3gS88s.js.gz +0 -0
  214. package/payload/server/public/assets/chat-IUOvVkL-.js +0 -1
  215. package/payload/server/public/assets/chat-IUOvVkL-.js.br +0 -1
  216. package/payload/server/public/assets/chat-IUOvVkL-.js.gz +0 -0
  217. package/payload/server/public/assets/chevron-left-DSzR9zR7.js +0 -1
  218. package/payload/server/public/assets/chevron-left-DSzR9zR7.js.br +0 -0
  219. package/payload/server/public/assets/clock-C5xE5p2I.js +0 -1
  220. package/payload/server/public/assets/clock-C5xE5p2I.js.br +0 -0
  221. package/payload/server/public/assets/copy-TkPJgllS.js.br +0 -0
  222. package/payload/server/public/assets/copy-TkPJgllS.js.gz +0 -0
  223. package/payload/server/public/assets/data-Bhv0Pvfl.js +0 -1
  224. package/payload/server/public/assets/data-Bhv0Pvfl.js.br +0 -0
  225. package/payload/server/public/assets/data-Bhv0Pvfl.js.gz +0 -0
  226. package/payload/server/public/assets/file-text-DyAL2DCh.js.br +0 -0
  227. package/payload/server/public/assets/file-text-DyAL2DCh.js.gz +0 -0
  228. package/payload/server/public/assets/graph-PiNIcCfX.js.br +0 -0
  229. package/payload/server/public/assets/graph-PiNIcCfX.js.gz +0 -0
  230. package/payload/server/public/assets/graph-labels-8jKFrLHn.js.br +0 -0
  231. package/payload/server/public/assets/graph-labels-8jKFrLHn.js.gz +0 -0
  232. package/payload/server/public/assets/operator-DKGKGzrb.js.br +0 -0
  233. package/payload/server/public/assets/operator-DKGKGzrb.js.gz +0 -0
  234. package/payload/server/public/assets/page-BU5vlY9Y.js.br +0 -0
  235. package/payload/server/public/assets/page-BU5vlY9Y.js.gz +0 -0
  236. package/payload/server/public/assets/page-DXz_K10G.js.br +0 -0
  237. package/payload/server/public/assets/page-DXz_K10G.js.gz +0 -0
  238. package/payload/server/public/assets/play-CmSyYrB6.js +0 -1
  239. package/payload/server/public/assets/play-CmSyYrB6.js.br +0 -0
  240. package/payload/server/public/assets/play-CmSyYrB6.js.gz +0 -0
  241. package/payload/server/public/assets/public-BmA3-jLj.js.br +0 -0
  242. package/payload/server/public/assets/public-BmA3-jLj.js.gz +0 -0
  243. package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.br +0 -0
  244. package/payload/server/public/assets/rotate-ccw-fCH0LSMq.js.gz +0 -0
  245. package/payload/server/public/assets/routines-BeFrmkls.js.br +0 -0
  246. package/payload/server/public/assets/routines-BeFrmkls.js.gz +0 -0
  247. package/payload/server/public/assets/skills-mjls5HBa.js.br +0 -0
  248. package/payload/server/public/assets/skills-mjls5HBa.js.gz +0 -0
  249. package/payload/server/public/assets/tasks-p6puHHD0.js.br +0 -0
  250. package/payload/server/public/assets/tasks-p6puHHD0.js.gz +0 -0
  251. package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.br +0 -0
  252. package/payload/server/public/assets/triangle-alert-CrZ1ewbd.js.gz +0 -0
  253. package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.br +0 -0
  254. package/payload/server/public/assets/useCopyFeedback-zOQNrIwd.js.gz +0 -0
  255. package/payload/server/public/assets/useMediaQuery-BmG090FR.js.br +0 -0
  256. package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.br +0 -0
  257. package/payload/server/public/assets/useVoiceRecorder-9pf6FFJ-.js.gz +0 -0
  258. package/payload/server/public/assets/wrench-k4GYG5rc.js.br +0 -0
  259. 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 these three need no hand step on a database that predates them.
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 INTEGER PRIMARY KEY AUTOINCREMENT,
274
- accountId TEXT NOT NULL,
275
- visitId TEXT NOT NULL,
276
- ownerId TEXT NOT NULL DEFAULT '',
277
- jobId TEXT NOT NULL DEFAULT '',
278
- status TEXT NOT NULL DEFAULT '',
279
- startDate TEXT NOT NULL DEFAULT '',
280
- purpose TEXT NOT NULL DEFAULT '',
281
- generation INTEGER NOT NULL DEFAULT 0,
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 INTEGER PRIMARY KEY AUTOINCREMENT,
329
- accountId TEXT NOT NULL,
330
- visitId TEXT NOT NULL,
331
- ownerId TEXT NOT NULL,
332
- status TEXT NOT NULL,
333
- statusAt TEXT NOT NULL,
334
- dayHundredths INTEGER NOT NULL DEFAULT 0,
335
- workDate TEXT NOT NULL DEFAULT '',
336
- workNote TEXT NOT NULL DEFAULT '',
337
- claimed INTEGER NOT NULL DEFAULT 0
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`,