@pilotiq/pilotiq 0.7.1 → 0.8.0

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 (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +154 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +31 -10
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +31 -9
package/src/routes.ts CHANGED
@@ -1,598 +1,21 @@
1
1
  import type { Router } from '@rudderjs/router'
2
- import type { AppRequest, AppResponse } from '@rudderjs/contracts'
3
- import { view } from '@rudderjs/view'
4
2
  import type { Pilotiq } from './Pilotiq.js'
5
3
  import { Form } from './elements/Form.js'
6
- import { resolveSchema, type SchemaContext } from './schema/resolveSchema.js'
7
4
  import { dispatchFormSubmit, findForms, selectForm } from './elements/dispatchForm.js'
8
- import { dispatchAction, findActions, findRowExtraActions, parseActionBody, type ResolveRecord } from './elements/dispatchAction.js'
9
- import { flashNotifications } from './notifications/flash.js'
10
- import {
11
- listFiltersKey,
12
- readPersistedListQuery,
13
- writePersistedListQuery,
14
- readPersistedLastTab,
15
- writePersistedLastTab,
16
- encodePersistedQuery,
17
- } from './sessionFilters.js'
18
- import {
19
- panelInfo, callPageSchema, tagFormActions, tagActionDispatch,
20
- dashboardData, resourceIndexData, resourceTableData,
21
- resourceCreateData, resourceEditData,
22
- resourceViewData, resourceRecordPageData,
23
- globalEditData, globalViewData, customPageData,
24
- formStateData, type FormStateScope,
25
- formWizardData,
26
- formCreateOptionData,
27
- mentionResolveData,
28
- searchData,
29
- relationManagerData, findRelatedResource, safeManagerPolicy,
30
- resolveRelationChain, type ResolvedChain,
31
- widgetData, type WidgetScope,
32
- } from './pageData.js'
33
- import {
34
- listForUser as listDatabaseNotifications,
35
- findOneForUser as findDatabaseNotificationForUser,
36
- markAsRead as markDatabaseNotificationAsRead,
37
- markAsUnread as markDatabaseNotificationAsUnread,
38
- markAllAsRead as markAllDatabaseNotificationsAsRead,
39
- } from './notifications/database.js'
40
- import { dispatchNotificationAction } from './notifications/dispatchNotificationAction.js'
41
- import { registerBroadcastAuth } from './notifications/registerBroadcastAuth.js'
42
- import {
43
- RelationManager, RESERVED_RELATIONSHIP_TOKENS,
44
- normalizeRelationMode,
45
- type RelationMode,
46
- } from './RelationManager.js'
47
- import {
48
- modelSave, modelLoadRecord, findRecord, getPrimaryKey, getRelationType,
49
- getMorphRelationDescriptor, computeMorphPayload,
50
- } from './orm/modelDefaults.js'
5
+ import { RESERVED_RELATIONSHIP_TOKENS } from './RelationManager.js'
51
6
  import { Table } from './elements/Table.js'
52
7
  import { Column } from './Column.js'
53
- import { coerceCellValue, CellCoerceError } from './cells/coerce.js'
54
- import type { ThemeConfig } from './theme/types.js'
55
- import { presets } from './theme/presets.js'
56
- import { baseColors } from './theme/base-colors.js'
57
- import { HUE_NAMES } from './theme/colors.js'
58
- import { migrateThemeOverrides } from './theme/migrate.js'
59
- import { radiusMap } from './theme/radius.js'
60
- import { resourceBasePath, globalBasePath, pageBasePath } from './clusterPaths.js'
61
8
  import type { ClusterClass } from './Cluster.js'
62
9
 
63
- /** True when the client wants a JSON response (modal-form action submitting
64
- * via fetch), false for a browser-style form post that wants a 303 redirect.
65
- * Both action endpoints honor this so confirm/handler buttons (form-post)
66
- * keep working unchanged while modal dialogs use fetch. */
67
- function wantsJson(req: AppRequest): boolean {
68
- const headers = req.headers ?? {}
69
- const accept = headers['accept'] ?? headers['Accept'] ?? ''
70
- return accept.includes('application/json')
71
- }
72
-
73
- /**
74
- * Read the request body as a `Record<string, unknown>`. The hono adapter
75
- * auto-parses JSON, but `application/x-www-form-urlencoded` and
76
- * `multipart/form-data` need a manual fall-through to Hono's own parser.
77
- */
78
- async function readFormBody(req: AppRequest): Promise<Record<string, unknown>> {
79
- if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
80
- return { ...(req.body as Record<string, unknown>) }
81
- }
82
- const raw = req.raw as { req?: { parseBody?: () => Promise<Record<string, unknown>> } } | undefined
83
- if (raw?.req?.parseBody) {
84
- try {
85
- const parsed = await raw.req.parseBody()
86
- return parsed && typeof parsed === 'object' ? { ...parsed } : {}
87
- } catch {
88
- return {}
89
- }
90
- }
91
- return {}
92
- }
93
-
94
- /**
95
- * Normalize a user-supplied redirect URL. Returns absolute URLs and
96
- * scheme-prefixed URLs unchanged. Bare relative paths (no leading `/`)
97
- * are joined under the panel's `basePath` — without this, the browser
98
- * resolves the redirect against the current request URL and produces
99
- * paths like `/admin/articles/{id}/articles/{id}/edit`.
100
- *
101
- * `getRedirectUrl` page hooks and `Form.redirectAfterSave` callbacks
102
- * are user-authored; this protects the framework against the common
103
- * authoring slip while keeping absolute URLs (the documented form)
104
- * working as-is.
105
- */
106
- function normalizeRedirect(url: string | undefined, basePath: string): string | undefined {
107
- if (!url) return undefined
108
- if (url.startsWith('/')) return url
109
- if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url // http(s):, mailto:, etc.
110
- const trimmedBase = basePath.replace(/\/$/, '')
111
- return `${trimmedBase}/${url}`
112
- }
113
-
114
- /** Strip framework meta keys (`_formId`, `_method`, `_continueCreate`)
115
- * from a parsed body. `continueCreate` mirrors the secondary
116
- * "Create & create another" submit on `CreatePage`: when `'1'`, the
117
- * create POST handler routes the redirect back to the create URL
118
- * instead of the new record's edit page. */
119
- function splitMeta(body: Record<string, unknown>): {
120
- values: Record<string, unknown>
121
- formId: string | undefined
122
- continueCreate: boolean
123
- } {
124
- const { _formId, _method: _omitMethod, _continueCreate, ...rest } = body
125
- return {
126
- values: rest,
127
- formId: typeof _formId === 'string' ? _formId : undefined,
128
- continueCreate: _continueCreate === '1' || _continueCreate === 1 || _continueCreate === true,
129
- }
130
- }
131
-
132
- /** Strip control characters (`"\\\r\n`) from a download filename so
133
- * the `Content-Disposition: attachment; filename="…"` header stays
134
- * unbreakable. Defends against a handler that returns a hostile
135
- * filename string. Empty fallback `'export'`. */
136
- function sanitizeFilename(name: string): string {
137
- const cleaned = (name ?? '').replace(/[\r\n"\\]/g, '').trim()
138
- return cleaned.length > 0 ? cleaned : 'export'
139
- }
140
-
141
- /** Write an `Action`-handler download envelope as the response. Sets
142
- * `Content-Type` + `Content-Disposition: attachment` and ends with
143
- * the body. Mutually exclusive with redirect — call sites consult
144
- * `result.download` first. */
145
- function sendDownload(
146
- res: AppResponse,
147
- env: { filename: string; contentType: string; body: string },
148
- ): void {
149
- res.header('Content-Type', env.contentType)
150
- res.header('Content-Disposition', `attachment; filename="${sanitizeFilename(env.filename)}"`)
151
- res.send(env.body)
152
- }
153
-
154
- /** Plan #10 — send a 403 response. Branches on `Accept: application/json`
155
- * the same way the action / form dispatch paths do. Used by every route
156
- * after a `Resource.canX(...)` check fails. We deliberately do NOT
157
- * redirect to login: 403 means "authenticated but not allowed"; the
158
- * 401-unauthenticated case is `Pilotiq.guard()`'s job. */
159
- function forbidden(res: AppResponse, json: boolean): unknown {
160
- res.status(403)
161
- if (json) return res.json({ ok: false, error: 'Forbidden' })
162
- return res.send('Forbidden')
163
- }
164
-
165
- /** Extract a user-facing message from a thrown value inside an editable
166
- * column's beforeStateUpdated / afterStateUpdated hook. Stamped under
167
- * the reserved `_cell` key in the 422 response. */
168
- function cellHookErrorMessage(err: unknown): string {
169
- if (err instanceof Error && err.message) return err.message
170
- if (typeof err === 'string' && err.length > 0) return err
171
- return 'Update halted'
172
- }
173
-
174
- /** Run a `canX(...)` predicate, treating throws as `false`. The predicate
175
- * is user-authored and we want a flaky check to fail closed (deny) rather
176
- * than 500 the page. */
177
- async function checkPolicy(fn: () => boolean | Promise<boolean>): Promise<boolean> {
178
- try { return Boolean(await fn()) } catch { return false }
179
- }
180
-
181
- async function policyAccess(
182
- owner: {
183
- canAccess: (user: unknown) => boolean | Promise<boolean>
184
- cluster?: { canAccess: (user: unknown) => boolean | Promise<boolean> }
185
- },
186
- user: unknown,
187
- ): Promise<boolean> {
188
- const [ownerOk, clusterOk] = await Promise.all([
189
- checkPolicy(() => owner.canAccess(user)),
190
- owner.cluster
191
- ? checkPolicy(() => owner.cluster!.canAccess(user))
192
- : Promise.resolve(true),
193
- ])
194
- return ownerOk && clusterOk
195
- }
196
-
197
- /**
198
- * Locate an action by name in a resolved page schema. Looks at both
199
- * page-level actions (`findActions`) AND row-scoped extraItemActions on
200
- * Repeater/Builder fields (`findRowExtraActions`). When the match is
201
- * row-scoped, also returns the parent field reference and the form
202
- * schema array — the dispatcher uses both to coerce the form body and
203
- * navigate to the right row when stamping `ctx.row`.
204
- *
205
- * Page-level matches win when a page-level + row-scoped action share the
206
- * same name (page-level is strictly more privileged: it has access to
207
- * the full form, not just one row). The collision is undocumented
208
- * behavior — authors should use distinct names.
209
- */
210
- function resolveDispatchTarget(
211
- elements: import('./schema/Element.js').Element[],
212
- actionName: string,
213
- ): {
214
- action: import('./actions/Action.js').Action
215
- rowField?: import('./fields/RepeaterField.js').RepeaterField | import('./fields/BuilderField.js').BuilderField
216
- formSchema?: import('./schema/Element.js').Element[]
217
- } | null {
218
- const pageLevel = findActions(elements).find(a => a.name === actionName)
219
- if (pageLevel) return { action: pageLevel }
220
-
221
- const rowMatches = findRowExtraActions(elements).filter(r => r.action.name === actionName)
222
- if (rowMatches.length === 0) return null
223
- if (rowMatches.length > 1) {
224
- console.warn(
225
- `[pilotiq] Action "${actionName}" registered as extraItemActions on multiple ` +
226
- `fields. Using the first match — disambiguate by renaming.`,
227
- )
228
- }
229
- const first = rowMatches[0]!
230
- // `formSchema` is the entire page tree for v1 — `coerceFormValues`
231
- // needs the field schema rooted at the form, not just the one row's
232
- // children. Passing the page tree is over-broad but safe (the function
233
- // walks until it finds the field). A future polish can narrow to the
234
- // owning Form once we walk back from the matched field.
235
- return { action: first.action, rowField: first.field, formSchema: elements }
236
- }
237
-
238
- /**
239
- * Plan #5 — handle a partial-resolve POST. The body shape is
240
- * `{ changed, values }`; `formId` comes from the URL path. Response
241
- * is `{ ok, form, dirty }` on success or `{ ok: false, error }` for
242
- * missing form / unknown field.
243
- */
244
- interface FormStateBody {
245
- changed?: unknown
246
- values?: unknown
247
- }
248
-
249
- async function handleFormState(
250
- req: AppRequest,
251
- res: AppResponse,
252
- pilotiq: Pilotiq,
253
- scope: FormStateScope,
254
- formId: string,
255
- ): Promise<unknown> {
256
- const body = (await readFormBody(req)) as FormStateBody
257
- const changed = typeof body.changed === 'string' ? body.changed : ''
258
- const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
259
- ? body.values as Record<string, unknown>
260
- : {}
261
- if (!formId || !changed) {
262
- res.status(400)
263
- return res.json({ ok: false, error: 'Missing formId or changed field' })
264
- }
265
-
266
- try {
267
- const result = await formStateData(pilotiq, scope, { formId, changed, values }, req)
268
- if (result === null) {
269
- res.status(404)
270
- return res.json({ ok: false, error: 'Page not found' })
271
- }
272
- if (!result.ok) {
273
- res.status(result.status)
274
- return res.json({ ok: false, error: result.error })
275
- }
276
- return res.json({ ok: true, form: result.form, dirty: result.dirty })
277
- } catch (err) {
278
- const message = err instanceof Error ? err.message : 'Form update failed'
279
- res.status(500)
280
- return res.json({ ok: false, error: message })
281
- }
282
- }
283
-
284
- interface FormWizardBody {
285
- step?: unknown
286
- values?: unknown
287
- }
288
-
289
- async function handleFormWizard(
290
- req: AppRequest,
291
- res: AppResponse,
292
- pilotiq: Pilotiq,
293
- scope: FormStateScope,
294
- formId: string,
295
- ): Promise<unknown> {
296
- const body = (await readFormBody(req)) as FormWizardBody
297
- const stepN = typeof body.step === 'number' ? body.step
298
- : typeof body.step === 'string' ? Number(body.step)
299
- : NaN
300
- const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
301
- ? body.values as Record<string, unknown>
302
- : {}
303
- if (!formId || !Number.isFinite(stepN) || stepN < 0) {
304
- res.status(400)
305
- return res.json({ ok: false, error: 'Missing formId or invalid step' })
306
- }
307
-
308
- try {
309
- const result = await formWizardData(pilotiq, scope, { formId, step: stepN, values }, req)
310
- if (result === null) {
311
- res.status(404)
312
- return res.json({ ok: false, error: 'Page not found' })
313
- }
314
- if (!result.ok) {
315
- res.status(result.status)
316
- const payload: Record<string, unknown> = { ok: false }
317
- if (result.error) payload['error'] = result.error
318
- if (result.errors) payload['errors'] = result.errors
319
- return res.json(payload)
320
- }
321
- return res.json({ ok: true })
322
- } catch (err) {
323
- const message = err instanceof Error ? err.message : 'Wizard step validation failed'
324
- res.status(500)
325
- return res.json({ ok: false, error: message })
326
- }
327
- }
328
-
329
- /**
330
- * Audit row 2026-05-07 cont'd⁸ — `SelectField.createOptionForm()` modal
331
- * submit. Body carries `{ values }`; `formId` + `fieldName` come from
332
- * the URL path. Returns `{ ok, option: { value, label } }` on success
333
- * or `{ ok: false, error }` for missing scope / form / field, 403 for
334
- * authorize failure, or 422 with `errors` for validation.
335
- *
336
- * One handler shared across all four scopes (resource-create /
337
- * resource-edit / global-edit / custom-page) — caller passes the
338
- * matching `FormStateScope` so the same `canAccess + canCreate / canEdit`
339
- * predicates apply to the parent form's policy gate.
340
- */
341
- interface FormCreateOptionBody {
342
- values?: unknown
343
- }
344
-
345
- async function handleFormCreateOption(
346
- req: AppRequest,
347
- res: AppResponse,
348
- pilotiq: Pilotiq,
349
- scope: FormStateScope,
350
- formId: string,
351
- fieldName: string,
352
- ): Promise<unknown> {
353
- const body = (await readFormBody(req)) as FormCreateOptionBody
354
- const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
355
- ? body.values as Record<string, unknown>
356
- : {}
357
- if (!formId || !fieldName) {
358
- res.status(400)
359
- return res.json({ ok: false, error: 'Missing formId or fieldName' })
360
- }
361
-
362
- try {
363
- const result = await formCreateOptionData(pilotiq, scope, { formId, fieldName, values }, req)
364
- if (result === null) {
365
- res.status(404)
366
- return res.json({ ok: false, error: 'Page not found' })
367
- }
368
- if (!result.ok) {
369
- res.status(result.status)
370
- const payload: Record<string, unknown> = { ok: false }
371
- if (result.error) payload['error'] = result.error
372
- if (result.errors) payload['errors'] = result.errors
373
- return res.json(payload)
374
- }
375
- return res.json({ ok: true, option: result.option })
376
- } catch (err) {
377
- const message = err instanceof Error ? err.message : 'createOption failed'
378
- res.status(500)
379
- return res.json({ ok: false, error: message })
380
- }
381
- }
382
-
383
- /**
384
- * Async-mention round-trip handler. Body is `{ field, trigger, query }`;
385
- * `formId` comes from the URL path. Returns `{ ok, items }` on success
386
- * or `{ ok: false, error }` for missing form / field / trigger.
387
- *
388
- * Each scope (resource-create, resource-edit, global-edit, custom-page)
389
- * registers its own route — the auth gate matches the matching `_form/
390
- * :formId/state` endpoint so the same `canAccess + canCreate / canEdit`
391
- * predicates apply.
392
- */
393
- interface FormMentionsBody {
394
- field?: unknown
395
- trigger?: unknown
396
- query?: unknown
397
- }
398
-
399
- async function handleFormMentions(
400
- req: AppRequest,
401
- res: AppResponse,
402
- pilotiq: Pilotiq,
403
- scope: FormStateScope,
404
- formId: string,
405
- ): Promise<unknown> {
406
- const body = (await readFormBody(req)) as FormMentionsBody
407
- const field = typeof body.field === 'string' ? body.field : ''
408
- const trigger = typeof body.trigger === 'string' ? body.trigger : ''
409
- const query = typeof body.query === 'string' ? body.query : ''
410
- if (!formId || !field || trigger.length !== 1) {
411
- res.status(400)
412
- return res.json({ ok: false, error: 'Missing formId / field / trigger' })
413
- }
414
-
415
- // Cap query length — the resolver runs the user's code; the trigger
416
- // never sends more than a word's worth of characters in practice.
417
- const cappedQuery = query.length > 200 ? query.slice(0, 200) : query
418
-
419
- try {
420
- const result = await mentionResolveData(
421
- pilotiq,
422
- scope,
423
- { formId, field, trigger, query: cappedQuery },
424
- req,
425
- )
426
- if (result === null) {
427
- res.status(404)
428
- return res.json({ ok: false, error: 'Page not found' })
429
- }
430
- if (!result.ok) {
431
- res.status(result.status)
432
- return res.json({ ok: false, error: result.error })
433
- }
434
- return res.json({ ok: true, items: result.items })
435
- } catch (err) {
436
- const message = err instanceof Error ? err.message : 'Mention resolve failed'
437
- res.status(500)
438
- return res.json({ ok: false, error: message })
439
- }
440
- }
441
-
442
- /**
443
- * Plan #15 — handle a widget polling POST. Body is `{ filter? }`;
444
- * `:id` comes from the URL. Returns `{ ok, data, timestamp }` on
445
- * success or `{ ok: false, error }` on failure. Used by lazy-loading
446
- * widgets (first fetch on mount) and `poll(seconds)` widgets (interval
447
- * re-fetch).
448
- */
449
- interface WidgetBody {
450
- filter?: unknown
451
- }
452
-
453
- async function handleWidgetData(
454
- req: AppRequest,
455
- res: AppResponse,
456
- pilotiq: Pilotiq,
457
- scope: WidgetScope,
458
- id: string,
459
- ): Promise<unknown> {
460
- if (!id) {
461
- res.status(400)
462
- return res.json({ ok: false, error: 'Missing widget id' })
463
- }
464
- const body = (await readFormBody(req)) as WidgetBody
465
- const filter = typeof body.filter === 'string' ? body.filter : undefined
466
-
467
- try {
468
- const result = await widgetData(
469
- pilotiq,
470
- scope,
471
- filter !== undefined ? { id, filter } : { id },
472
- req,
473
- )
474
- if (!result.ok) {
475
- res.status(result.status)
476
- return res.json({ ok: false, error: result.error })
477
- }
478
- return res.json({ ok: true, data: result.data, timestamp: result.timestamp })
479
- } catch (err) {
480
- res.status(500)
481
- return res.json({ ok: false, error: err instanceof Error ? err.message : 'Widget request failed' })
482
- }
483
- }
484
-
485
- /**
486
- * Handle a single file upload from a `FileUpload` field. Validates
487
- * accept / maxSize against the (optional) per-request hints, hands
488
- * the file off to the configured adapter, returns `{ ok, url }`.
489
- *
490
- * Body shape (multipart/form-data):
491
- * - `file`: the file blob
492
- * - `directory`: optional sub-directory hint
493
- * - `accept`: optional comma-separated MIME list to enforce
494
- * - `maxSize`: optional byte cap
495
- * - `fieldName`: optional tag forwarded to the adapter for routing
496
- */
497
- async function handleUploadRequest(
498
- req: AppRequest,
499
- res: AppResponse,
500
- pilotiq: Pilotiq,
501
- ): Promise<unknown> {
502
- const cfg = pilotiq.getConfig()
503
- if (!cfg.uploads) {
504
- res.status(500)
505
- return res.json({ ok: false, error: 'No upload adapter configured' })
506
- }
507
-
508
- // Auth: panel-wide `guard` and per-request `user`. We don't enforce
509
- // per-resource canEdit here because the field doesn't know which
510
- // resource it belongs to — apps that need it should hook into
511
- // their adapter's `put()` and consult their own auth there.
512
- if (cfg.guard && !await cfg.guard(req)) {
513
- res.status(401)
514
- return res.json({ ok: false, error: 'Unauthorized' })
515
- }
516
-
517
- // Parse multipart body. Hono's parseBody returns `Record<string, File | string>`.
518
- const raw = req.raw as { req?: { parseBody?: (opts?: { all?: boolean }) => Promise<Record<string, unknown>> } } | undefined
519
- if (!raw?.req?.parseBody) {
520
- res.status(500)
521
- return res.json({ ok: false, error: 'Multipart parsing unavailable' })
522
- }
523
- let body: Record<string, unknown>
524
- try {
525
- body = await raw.req.parseBody()
526
- } catch (err) {
527
- res.status(400)
528
- return res.json({ ok: false, error: err instanceof Error ? err.message : 'Bad request' })
529
- }
530
-
531
- const file = body['file']
532
- if (!file || !(file instanceof File)) {
533
- res.status(422)
534
- return res.json({ ok: false, error: 'No file provided' })
535
- }
536
-
537
- const directory = typeof body['directory'] === 'string' ? body['directory'] : undefined
538
- const fieldName = typeof body['fieldName'] === 'string' ? body['fieldName'] : ''
539
-
540
- // Server-side validation. Both accept and maxSize are advisory hints
541
- // shipped by the field meta, so we re-check here so a tampered client
542
- // can't bypass the limits.
543
- const acceptStr = typeof body['accept'] === 'string' ? body['accept'] : ''
544
- if (acceptStr) {
545
- const accept = acceptStr.split(',').map(s => s.trim()).filter(Boolean)
546
- if (accept.length > 0 && !accept.includes(file.type)) {
547
- res.status(422)
548
- return res.json({ ok: false, error: `File type "${file.type}" not allowed` })
549
- }
550
- }
551
- const maxSizeStr = typeof body['maxSize'] === 'string' ? body['maxSize'] : ''
552
- if (maxSizeStr) {
553
- const maxSize = Number(maxSizeStr)
554
- if (Number.isFinite(maxSize) && file.size > maxSize) {
555
- res.status(422)
556
- return res.json({ ok: false, error: `File exceeds ${maxSize} bytes` })
557
- }
558
- }
559
-
560
- // Server-side resize via @rudderjs/image (optional peer dep). Variable-
561
- // string `import(name)` keeps Vite's static import-analysis from trying
562
- // to pre-resolve the module on host apps that don't have @rudderjs/image
563
- // installed — same pattern as `notifications/database.ts` for `@rudderjs/orm`.
564
- const resizeWidthStr = typeof body['resize_width'] === 'string' ? body['resize_width'] : ''
565
- const resizeHeightStr = typeof body['resize_height'] === 'string' ? body['resize_height'] : ''
566
- let uploadFile: File = file
567
- if (resizeWidthStr && resizeHeightStr) {
568
- const w = Number(resizeWidthStr)
569
- const h = Number(resizeHeightStr)
570
- if (Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0) {
571
- try {
572
- const imageModuleName = '@rudderjs/image'
573
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
574
- const pkg = await import(/* @vite-ignore */ imageModuleName) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
575
- const buf = await pkg.image(file).resize(w, h).format('webp').toBuffer()
576
- const baseName = file.name.replace(/\.[^.]+$/, '')
577
- uploadFile = new File([buf.buffer as ArrayBuffer], `${baseName}.webp`, { type: 'image/webp' })
578
- } catch {
579
- // @rudderjs/image not installed or resize failed — fall through with original file
580
- }
581
- }
582
- }
583
-
584
- try {
585
- const result = await cfg.uploads.adapter.put({
586
- file: uploadFile,
587
- ...(directory ? { directory } : {}),
588
- fieldName,
589
- })
590
- return res.json({ ok: true, url: result.url, ...(result.meta ? { meta: result.meta } : {}) })
591
- } catch (err) {
592
- res.status(500)
593
- return res.json({ ok: false, error: err instanceof Error ? err.message : 'Upload failed' })
594
- }
595
- }
10
+ // `routes.ts` is split into a directory of focused modules under
11
+ // `./routes/`. This file is the orchestrator — boot-time validation
12
+ // loops + the per-Resource / per-Global / per-Page registration
13
+ // dispatchers. See `docs/plans/routes-split.md` for the per-phase map.
14
+ import { registerPanelRoutes } from './routes/panel.js'
15
+ import { registerResourceRoutes } from './routes/resources.js'
16
+ import { registerGlobalRoutes } from './routes/globals.js'
17
+ import { registerCustomPageRoutes } from './routes/pages.js'
18
+ import { registerThemeRoutes } from './routes/theme.js'
596
19
 
597
20
  export function registerPilotiqRoutes(
598
21
  router: Router,
@@ -750,2621 +173,86 @@ export function registerPilotiqRoutes(
750
173
  }
751
174
  }
752
175
 
753
- // Reorderable rows — fail fast at boot when a Resource declares
754
- // `Table.reorderable()` but the bound model can't actually persist a
755
- // new order. We invoke `R.table(Table.make())` once per resource (the
756
- // same call shape `defaultPages` uses at request time) and inspect
757
- // `_reorderableColumn`. The model.reorder check is symmetric with
758
- // Plan #13's restore/forceDelete guards. Result is cached per-resource
759
- // so the route loop below can decide whether to mount `_reorder`.
176
+ // Reorderable rows + editable cell columns — fail fast at boot when a
177
+ // Resource declares either capability but its bound model can't
178
+ // persist. We invoke `R.table(Table.make())` once per resource (same
179
+ // call shape `defaultPages` uses at request time) and inspect both
180
+ // markers in a single pass. The model.reorder / model.update checks
181
+ // are symmetric with Plan #13's restore/forceDelete guards. Results
182
+ // cached per-resource so the route loop below can decide whether to
183
+ // mount `_reorder` / `_cell`.
760
184
  const reorderEnabled = new Map<string, string>() // slug → column
761
- for (const R of cfg.resources) {
762
- let probeColumn: string | undefined
763
- try { probeColumn = R.table(Table.make()).getReorderableColumn() }
764
- catch { continue } // user-side throw — not a reorder concern
765
- if (probeColumn === undefined) continue
766
- if (!R.model || typeof R.model.reorder !== 'function') {
767
- throw new Error(
768
- `[Pilotiq] ${R.name}.table() calls reorderable("${probeColumn}") but the bound model has no reorder(ids) method. ` +
769
- `Implement \`async reorder(ids)\` on the rudder Model (or remove the .reorderable() call).`,
770
- )
771
- }
772
- reorderEnabled.set(R.getSlug(), probeColumn)
773
- }
774
-
775
- // Editable cell columns — fail fast at boot when a Resource declares
776
- // at least one TextInput/Toggle/SelectColumn but the bound model
777
- // can't persist a single-column update. Mirrors the reorder guard
778
- // above. Result is cached per-resource so the route loop below can
779
- // decide whether to mount `_cell`.
780
185
  const editableEnabled = new Set<string>()
781
186
  for (const R of cfg.resources) {
782
- let hasEditable = false
783
- try {
784
- hasEditable = (R.table(Table.make()).getChildren() ?? [])
785
- .some(c => c instanceof Column && c.isEditable())
786
- } catch { continue }
787
- if (!hasEditable) continue
788
- if (!R.model || typeof R.model.update !== 'function') {
789
- throw new Error(
790
- `[Pilotiq] ${R.name}.table() declares an editable cell column ` +
791
- `(TextInputColumn / ToggleColumn / SelectColumn) but the bound ` +
792
- `model has no update(id, data) method. Set Resource.model = M ` +
793
- `(rudder ORM convention) or drop the editable column.`,
794
- )
795
- }
796
- editableEnabled.add(R.getSlug())
797
- }
798
-
799
- // ── Dashboard (1-segment) ─────────────────────────────
800
- router.get(base, async (req, res) => {
801
- // Plan #15 — when `panel.dashboard(P)` is set, gate the dashboard
802
- // route through the page's `canAccess` predicate. Same posture as
803
- // custom pages — fail-closed on throw.
804
- if (cfg.dashboardPage) {
805
- const user = await pilotiq.resolveUser(req)
806
- if (!await policyAccess(cfg.dashboardPage!, user)) {
807
- return forbidden(res, wantsJson(req))
808
- }
809
- }
810
- return view('pilotiq.dashboard', await dashboardData(pilotiq, req))
811
- })
812
-
813
- // ── File uploads (FileUpload field POST target) ───────
814
- router.post(`${base}/_uploads`, async (req, res) => {
815
- return handleUploadRequest(req, res, pilotiq)
816
- })
817
-
818
- // ── Plan #15 dashboard widget polling ─────────────────
819
- // POST ${base}/_widget/:id — re-resolves the dashboard page schema,
820
- // finds widget by id, runs `getServerData(ctx)`. Body: `{ filter? }`.
821
- // Mounted unconditionally — widgetData() returns 404 when no
822
- // dashboard page is registered, so this stays cheap when unused.
823
- router.post(`${base}/_widget/:id`, async (req, res) => {
824
- if (cfg.dashboardPage) {
825
- const user = await pilotiq.resolveUser(req)
826
- if (!await policyAccess(cfg.dashboardPage!, user)) return forbidden(res, true)
827
- }
828
- return handleWidgetData(req, res, pilotiq, { kind: 'panel' }, req.params['id']!)
829
- })
830
-
831
- // ── Plan #12 global search ────────────────────────────
832
- // GET ${base}/_search?q=…&limit=… → { ok, results }
833
- // No 403 on unrecognised users — `searchAllResources` filters per
834
- // resource. The Pilotiq.guard() layer above is the panel-level gate.
835
- router.get(`${base}/_search`, async (req, res) => {
836
- const query = req.query as Record<string, unknown> | undefined
837
- const rawQ = query?.['q']
838
- const q = typeof rawQ === 'string' ? rawQ.slice(0, 200) : ''
839
- const data = await searchData(pilotiq, q, req)
840
- return res.json(data)
841
- })
842
-
843
- // ── Database notifications (bell-icon dropdown) ───────
844
- // Only mounted when `Pilotiq.databaseNotifications()` was called.
845
- // Every route 401s when no user resolves so a non-authenticated
846
- // request never sees another user's inbox. The `notifiable_type`
847
- // value is configurable but defaults to `'users'` to match
848
- // `@rudderjs/notification`'s `DatabaseChannel` writes.
849
- if (cfg.databaseNotifications?.enabled) {
850
- const dn = cfg.databaseNotifications
851
- const notifiableType = dn.notifiableType ?? 'users'
852
- const pageSize = dn.pageSize ?? 25
853
-
854
- /** Resolve `{ id }` from the panel's user resolver. Returns null
855
- * when no user / unknown id — every route then 401s. The user
856
- * object is opaque to pilotiq; we duck-type `.id`. */
857
- const resolveUserId = async (req: AppRequest): Promise<string | null> => {
858
- const user = await pilotiq.resolveUser(req)
859
- if (!user || typeof user !== 'object') return null
860
- const id = (user as { id?: unknown }).id
861
- if (id === undefined || id === null) return null
862
- return String(id)
863
- }
864
-
865
- // GET ${base}/_notifications → { notifications, unreadCount }
866
- router.get(`${base}/_notifications`, async (req, res) => {
867
- const id = await resolveUserId(req)
868
- if (id === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
869
- const url = new URL(req.url ?? '/', 'http://localhost')
870
- const unreadOnly = url.searchParams.get('unread') === 'true'
871
- const limitRaw = Number(url.searchParams.get('limit') ?? pageSize)
872
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : pageSize
873
- const data = await listDatabaseNotifications({
874
- notifiableType,
875
- notifiableId: id,
876
- limit,
877
- unreadOnly,
878
- })
879
- return res.json({ ok: true, ...data })
880
- })
881
-
882
- // POST ${base}/_notifications/:id/read
883
- router.post(`${base}/_notifications/:id/read`, async (req, res) => {
884
- const userId = await resolveUserId(req)
885
- if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
886
- const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
887
- const updated = await markDatabaseNotificationAsRead(rowId, {
888
- notifiableType,
889
- notifiableId: userId,
890
- })
891
- return res.json({ ok: updated })
892
- })
893
-
894
- // POST ${base}/_notifications/:id/unread
895
- router.post(`${base}/_notifications/:id/unread`, async (req, res) => {
896
- const userId = await resolveUserId(req)
897
- if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
898
- const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
899
- const updated = await markDatabaseNotificationAsUnread(rowId, {
900
- notifiableType,
901
- notifiableId: userId,
902
- })
903
- return res.json({ ok: updated })
904
- })
905
-
906
- // POST ${base}/_notifications/read-all
907
- router.post(`${base}/_notifications/read-all`, async (req, res) => {
908
- const userId = await resolveUserId(req)
909
- if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
910
- const count = await markAllDatabaseNotificationsAsRead({
911
- notifiableType,
912
- notifiableId: userId,
913
- })
914
- return res.json({ ok: true, count })
915
- })
916
-
917
- // POST ${base}/_notifications/:id/_action/:actionName
918
- //
919
- // Notification action dispatch — looks up the stored action on the
920
- // row, resolves the named handler against the panel's
921
- // `notificationHandlers` registry, and runs it with the row's
922
- // stored payload. Optionally flips `read_at` server-side when the
923
- // action carried `markAsRead: true`.
924
- //
925
- // Defends in depth: 404s on missing row / wrong owner / action
926
- // missing / non-string handler / unknown registry name. Body is
927
- // ignored — payload reads exclusively from the stored row, so a
928
- // tampered client can't inject extra payload keys.
929
- router.post(`${base}/_notifications/:id/_action/:actionName`, async (req, res) => {
930
- const user = await pilotiq.resolveUser(req)
931
- const userId = user && typeof user === 'object'
932
- ? ((user as { id?: unknown }).id !== undefined && (user as { id?: unknown }).id !== null
933
- ? String((user as { id: unknown }).id) : null)
934
- : null
935
- if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
936
-
937
- const params = (req.params as Record<string, string | undefined>)
938
- const result = await dispatchNotificationAction(pilotiq, {
939
- notificationId: params['id'] ?? '',
940
- actionName: params['actionName'] ?? '',
941
- notifiableType,
942
- notifiableId: userId,
943
- user,
944
- request: req,
945
- })
946
- if (!result.ok) {
947
- res.status(result.status)
948
- return res.json({ ok: false, error: result.error })
949
- }
950
- return res.json(result)
951
- })
952
-
953
- // Phase 2 — register the broadcast auth callback for private
954
- // `pilotiq-notifications.<userId>` channels. Soft-fails when
955
- // `@rudderjs/broadcast` isn't installed; apps that haven't enabled
956
- // broadcast on the toggle stay quiet either way.
957
- void registerBroadcastAuth(pilotiq)
958
- }
959
-
960
- // ── Resource routes ───────────────────────────────────
961
- for (const R of cfg.resources) {
962
- const slug = R.getSlug()
963
- const resourceBase = resourceBasePath(base, R)
964
- const pages = R.resolvePages()
965
-
966
- // Index — GET ${resourceBase}
967
- if (pages.index) {
968
- const PageClass = pages.index
969
- const indexUrl = resourceBase
970
- router.get(indexUrl, async (req, res) => {
971
- const user = await pilotiq.resolveUser(req)
972
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
973
- if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, wantsJson(req))
974
-
975
- if (R.persistFiltersInSession) {
976
- const query = (req.query as Record<string, unknown> | undefined) ?? {}
977
- const sessionSlug = resourceBase.slice(base.length + 1)
978
- if (Object.keys(query).length === 0) {
979
- const restoreTab = readPersistedLastTab(req, base, sessionSlug) ?? ''
980
- const stored = readPersistedListQuery(req, listFiltersKey(base, sessionSlug, restoreTab))
981
- if (stored) {
982
- const qs = encodePersistedQuery(stored, restoreTab)
983
- if (qs !== '') return res.redirect(`${indexUrl}?${qs}`, 302)
984
- }
985
- } else {
986
- const tab = typeof query['tab'] === 'string' ? query['tab'] : ''
987
- writePersistedListQuery(req, listFiltersKey(base, sessionSlug, tab), query)
988
- writePersistedLastTab(req, base, sessionSlug, tab)
989
- }
990
- }
991
-
992
- const data = await resourceIndexData(pilotiq, slug, req.query, req)
993
- return view('pilotiq.slug', data ?? {})
994
- })
995
-
996
- router.post(`${indexUrl}/_widget/:id`, async (req, res) => {
997
- const user = await pilotiq.resolveUser(req)
998
- if (!await policyAccess(R, user)) return forbidden(res, true)
999
- if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
1000
- return handleWidgetData(req, res, pilotiq, { kind: 'resource', slug }, req.params['id']!)
1001
- })
1002
-
1003
- if (R.deferLoading) {
1004
- router.get(`${indexUrl}/_table`, async (req, res) => {
1005
- const user = await pilotiq.resolveUser(req)
1006
- if (!await policyAccess(R, user)) return forbidden(res, true)
1007
- if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
1008
- const data = await resourceTableData(pilotiq, slug, req.query as Record<string, string>, req)
1009
- if (!data) { res.status(404); return res.json({ ok: false, error: 'Resource not found' }) }
1010
- return res.json({ ok: true, ...data })
1011
- })
1012
- }
1013
-
1014
- // Action dispatch — POST ${resourceBase}/_action/:actionName
1015
- router.post(`${indexUrl}/_action/:actionName`, async (req, res) => {
1016
- const user = await pilotiq.resolveUser(req)
1017
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1018
-
1019
- const actionName = req.params['actionName']!
1020
- const json = wantsJson(req)
1021
- const body = await readFormBody(req)
1022
- const input = parseActionBody(body)
1023
-
1024
- const ctx: SchemaContext = { mode: 'table', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1025
- const elements = await callPageSchema(PageClass, ctx)
1026
- tagActionDispatch(elements, indexUrl)
1027
- const target = resolveDispatchTarget(elements, actionName)
1028
- if (!target) {
1029
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1030
- res.status(404)
1031
- return res.send(`Action "${actionName}" not found on ${R.label}`)
1032
- }
1033
-
1034
- const resolveRecord: ResolveRecord | undefined = R.model
1035
- ? (id: string) => findRecord(R, id, { user })
1036
- : undefined
1037
-
1038
- const result = await dispatchAction(target.action, {
1039
- ...input,
1040
- request: req,
1041
- user,
1042
- ...(target.rowField ? { rowField: target.rowField } : {}),
1043
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1044
- }, resolveRecord)
1045
- if (!result.ok) {
1046
- if (json) {
1047
- res.status(result.errors ? 422 : 500)
1048
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
1049
- }
1050
- res.status(500)
1051
- return res.send(result.error)
1052
- }
1053
- // Download envelope wins over redirect — `Action.export` and friends
1054
- // return the file body inline. Notifications dropped on this branch
1055
- // because the binary response has no JSON envelope to carry them;
1056
- // the file itself is the success signal.
1057
- if (result.download) return sendDownload(res, result.download)
1058
- const redirect = normalizeRedirect(result.redirect, base) ?? indexUrl
1059
- if (json) {
1060
- return res.json({
1061
- ok: true,
1062
- redirect,
1063
- ...(result.notifications ? { notifications: result.notifications } : {}),
1064
- })
1065
- }
1066
- flashNotifications(req, result.notifications)
1067
- return res.redirect(redirect, 303)
1068
- })
1069
-
1070
- // Reorderable rows — POST ${resourceBase}/_reorder { ids: [] }
1071
- // Only mounted when `Resource.table()` opts in (boot-time probe
1072
- // populates `reorderEnabled`).
1073
- if (reorderEnabled.has(slug)) {
1074
- router.post(`${indexUrl}/_reorder`, async (req, res) => {
1075
- const user = await pilotiq.resolveUser(req)
1076
- if (!await policyAccess(R, user)) return forbidden(res, true)
1077
- // List-level edit gate. The drop affects many rows at once;
1078
- // there's no single record to authorize against, so we pass
1079
- // `undefined` and let user-supplied `canEdit` overrides branch
1080
- // on `record === undefined` if they want row-level granularity.
1081
- if (!await checkPolicy(() => R.canEdit(user, undefined))) return forbidden(res, true)
1082
-
1083
- const body = await readFormBody(req)
1084
- const raw = (body as { ids?: unknown }).ids
1085
- if (!Array.isArray(raw) || raw.length === 0) {
1086
- res.status(400)
1087
- return res.json({ ok: false, error: 'Missing or empty ids array' })
1088
- }
1089
- const ids = raw.filter((id): id is string | number =>
1090
- typeof id === 'string' || typeof id === 'number',
1091
- )
1092
- if (ids.length !== raw.length) {
1093
- res.status(400)
1094
- return res.json({ ok: false, error: 'ids must contain only strings or numbers' })
1095
- }
1096
-
1097
- try {
1098
- // Boot already verified `R.model?.reorder` exists; the `!`
1099
- // assertions are safe.
1100
- await R.model!.reorder!(ids)
1101
- return res.json({ ok: true })
1102
- } catch (err) {
1103
- res.status(422)
1104
- return res.json({
1105
- ok: false,
1106
- error: err instanceof Error ? err.message : 'Reorder failed',
1107
- })
1108
- }
1109
- })
1110
- }
1111
-
1112
- // Editable cell columns — POST ${resourceBase}/:id/_cell/:column
1113
- // { value: <coerced> }. Only mounted when the resource declares at
1114
- // least one editable column (boot-time probe populates
1115
- // `editableEnabled`).
1116
- if (editableEnabled.has(slug)) {
1117
- router.post(`${indexUrl}/:id/_cell/:column`, async (req, res) => {
1118
- const user = await pilotiq.resolveUser(req)
1119
- if (!await policyAccess(R, user)) return forbidden(res, true)
1120
-
1121
- const id = req.params['id']!
1122
- const colName = req.params['column']!
1123
-
1124
- // Locate the column on the table. We re-derive `Table.make()`
1125
- // here (same probe shape used by the boot guard + reorder route)
1126
- // so the column instance carries its validators / discriminator.
1127
- const probe = R.table(Table.make())
1128
- const col = (probe.getChildren() ?? [])
1129
- .find((c): c is Column => c instanceof Column && c.name === colName)
1130
- if (!col) {
1131
- res.status(400)
1132
- return res.json({ ok: false, error: `Unknown column "${colName}"` })
1133
- }
1134
- if (!col.isEditable()) {
1135
- res.status(400)
1136
- return res.json({ ok: false, error: `Column "${colName}" is not editable` })
1137
- }
1138
-
1139
- // Boot already verified `R.model?.update`; the `!` is safe.
1140
- const record = await findRecord(R, id, { user })
1141
- if (record === null || record === undefined) {
1142
- res.status(404)
1143
- return res.json({ ok: false, error: 'Record not found' })
1144
- }
1145
- if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, true)
1146
-
1147
- const body = await readFormBody(req)
1148
- const raw = (body as { value?: unknown }).value
1149
-
1150
- let value: unknown
1151
- try { value = coerceCellValue(col, raw) }
1152
- catch (err) {
1153
- const message = err instanceof CellCoerceError ? err.message
1154
- : err instanceof Error ? err.message
1155
- : 'Invalid value'
1156
- res.status(422)
1157
- return res.json({ ok: false, errors: { value: [message] } })
1158
- }
1159
-
1160
- const errors = await col.runValidators(value, { record })
1161
- if (errors.length > 0) {
1162
- res.status(422)
1163
- return res.json({ ok: false, errors: { value: errors } })
1164
- }
1165
-
1166
- // beforeStateUpdated — runs after validators pass, before the
1167
- // DB write. Throwing halts with 422 under `_cell`.
1168
- const beforeHook = col.getBeforeStateUpdated()
1169
- if (beforeHook) {
1170
- try { await beforeHook(value, { record: record as Record<string, unknown>, user }) }
1171
- catch (err) {
1172
- res.status(422)
1173
- return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1174
- }
1175
- }
1176
-
1177
- try {
1178
- await R.model!.update(id, { [col.name]: value })
1179
- } catch (err) {
1180
- res.status(422)
1181
- return res.json({
1182
- ok: false,
1183
- error: err instanceof Error ? err.message : 'Update failed',
1184
- })
1185
- }
1186
-
1187
- // afterStateUpdated — runs only on a confirmed write. Throwing
1188
- // surfaces the error to the user; the DB row is already
1189
- // updated (the hook is for follow-up effects, not rollback).
1190
- const afterHook = col.getAfterStateUpdated()
1191
- if (afterHook) {
1192
- try { await afterHook(value, { record: record as Record<string, unknown>, user }) }
1193
- catch (err) {
1194
- res.status(422)
1195
- return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
1196
- }
1197
- }
1198
-
1199
- return res.json({ ok: true, value, notifications: [] })
1200
- })
1201
- }
1202
- }
1203
-
1204
- // Plan #5 — partial-resolve endpoint for create-mode forms.
1205
- // POST ${resourceBase}/_form/:formId/state
1206
- if (pages.create) {
1207
- router.post(`${resourceBase}/_form/:formId/state`, async (req, res) => {
1208
- const user = await pilotiq.resolveUser(req)
1209
- if (!await policyAccess(R, user)) return forbidden(res, true)
1210
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1211
- const formId = req.params['formId']!
1212
- return handleFormState(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1213
- })
1214
-
1215
- // Plan #8 — wizard step-validate endpoint for create-mode forms.
1216
- router.post(`${resourceBase}/_form/:formId/wizard`, async (req, res) => {
1217
- const user = await pilotiq.resolveUser(req)
1218
- if (!await policyAccess(R, user)) return forbidden(res, true)
1219
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1220
- const formId = req.params['formId']!
1221
- return handleFormWizard(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1222
- })
1223
-
1224
- // Async-mention endpoint for create-mode forms.
1225
- router.post(`${resourceBase}/_form/:formId/mentions`, async (req, res) => {
1226
- const user = await pilotiq.resolveUser(req)
1227
- if (!await policyAccess(R, user)) return forbidden(res, true)
1228
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1229
- const formId = req.params['formId']!
1230
- return handleFormMentions(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
1231
- })
1232
-
1233
- // SelectField inline-create modal endpoint for create-mode forms.
1234
- router.post(`${resourceBase}/_form/:formId/create-option/:fieldName`, async (req, res) => {
1235
- const user = await pilotiq.resolveUser(req)
1236
- if (!await policyAccess(R, user)) return forbidden(res, true)
1237
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
1238
- const formId = req.params['formId']!
1239
- const fieldName = req.params['fieldName']!
1240
- return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-create', slug }, formId, fieldName)
1241
- })
1242
- }
1243
-
1244
- // Plan #5 — partial-resolve endpoint for edit-mode forms.
1245
- // POST ${resourceBase}/:id/_form/:formId/state
1246
- if (pages.edit) {
1247
- router.post(`${resourceBase}/:id/_form/:formId/state`, async (req, res) => {
1248
- const recordId = req.params['id']!
1249
- const formId = req.params['formId']!
1250
- const user = await pilotiq.resolveUser(req)
1251
- if (!await policyAccess(R, user)) return forbidden(res, true)
1252
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1253
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1254
- return handleFormState(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1255
- })
1256
-
1257
- // Plan #8 — wizard step-validate endpoint for edit-mode forms.
1258
- router.post(`${resourceBase}/:id/_form/:formId/wizard`, async (req, res) => {
1259
- const recordId = req.params['id']!
1260
- const formId = req.params['formId']!
1261
- const user = await pilotiq.resolveUser(req)
1262
- if (!await policyAccess(R, user)) return forbidden(res, true)
1263
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1264
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1265
- return handleFormWizard(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1266
- })
1267
-
1268
- // Async-mention endpoint for edit-mode forms.
1269
- router.post(`${resourceBase}/:id/_form/:formId/mentions`, async (req, res) => {
1270
- const recordId = req.params['id']!
1271
- const formId = req.params['formId']!
1272
- const user = await pilotiq.resolveUser(req)
1273
- if (!await policyAccess(R, user)) return forbidden(res, true)
1274
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1275
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1276
- return handleFormMentions(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
1277
- })
1278
-
1279
- // SelectField inline-create modal endpoint for edit-mode forms.
1280
- router.post(`${resourceBase}/:id/_form/:formId/create-option/:fieldName`, async (req, res) => {
1281
- const recordId = req.params['id']!
1282
- const formId = req.params['formId']!
1283
- const fieldName = req.params['fieldName']!
1284
- const user = await pilotiq.resolveUser(req)
1285
- if (!await policyAccess(R, user)) return forbidden(res, true)
1286
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1287
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
1288
- return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId, fieldName)
1289
- })
1290
- }
1291
-
1292
- // Create — GET ${resourceBase}/create
1293
- if (pages.create) {
1294
- const PageClass = pages.create
1295
- const createUrl = `${resourceBase}/create`
1296
-
1297
- router.get(createUrl, async (req, res) => {
1298
- const user = await pilotiq.resolveUser(req)
1299
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1300
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1301
- const data = await resourceCreateData(pilotiq, slug, undefined, req)
1302
- return view('pilotiq.resource-create', data ?? {})
1303
- })
1304
-
1305
- // Create — POST ${resourceBase}/create
1306
- router.post(createUrl, async (req, res) => {
1307
- const user = await pilotiq.resolveUser(req)
1308
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1309
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1310
-
1311
- const body = await readFormBody(req)
1312
- const { values, formId, continueCreate } = splitMeta(body)
1313
- const json = wantsJson(req)
187
+ let probe: ReturnType<typeof Table.make> | undefined
188
+ try { probe = R.table(Table.make()) }
189
+ catch { continue } // user-side throw — neither flag applies
1314
190
 
1315
- const ctx: SchemaContext = { mode: 'create', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1316
- const elements = await callPageSchema(PageClass, ctx)
1317
- tagFormActions(elements, createUrl)
1318
- const form = selectForm(findForms(elements), formId)
1319
- if (!form) {
1320
- if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
1321
- res.status(404)
1322
- return res.send('No form found on page')
1323
- }
1324
-
1325
- const result = await dispatchFormSubmit(form, values, {
1326
- values,
1327
- basePath: base,
1328
- ...(R.model ? { parentModel: R.model } : {}),
1329
- })
1330
-
1331
- if (!result.ok) {
1332
- if (json) {
1333
- res.status(422)
1334
- return res.json({ ok: false, errors: result.errors })
1335
- }
1336
- // Re-render through the same builder so the page is identical to GET,
1337
- // just with values + errors prefilled.
1338
- const data = await resourceCreateData(pilotiq, slug, { values, errors: result.errors })
1339
- res.status(422)
1340
- return view('pilotiq.resource-create', data ?? {})
1341
- }
1342
-
1343
- const recordId = (result.record as { id?: unknown })?.id
1344
- // "Create & create another" — when the secondary submit fired,
1345
- // route back to the create page with a fresh form. Skips any
1346
- // user-supplied `redirectAfterSave`: the user clicked the
1347
- // button asking explicitly to create another, so the
1348
- // continue-intent wins. `force: true` tells the SPA-mode
1349
- // FormRenderer to navigate even though the redirect URL
1350
- // matches the current page (otherwise the same-URL skip
1351
- // would preserve the just-submitted values on screen).
1352
- const fallback = continueCreate
1353
- ? createUrl
1354
- : recordId !== undefined ? `${resourceBase}/${String(recordId)}/edit` : `${resourceBase}`
1355
- const redirect = continueCreate
1356
- ? createUrl
1357
- : normalizeRedirect(result.redirect, base) ?? fallback
1358
- if (json) {
1359
- return res.json({
1360
- ok: true,
1361
- redirect,
1362
- ...(continueCreate ? { force: true } : {}),
1363
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1364
- })
1365
- }
1366
- flashNotifications(req, result.notifications)
1367
- return res.redirect(redirect, 303)
1368
- })
1369
-
1370
- // Action dispatch — POST ${createUrl}/_action/:actionName
1371
- // Handles both page-level handler-style actions AND Repeater /
1372
- // Builder `extraItemActions` rows. The latter pass `_rowPath` in
1373
- // the body so the dispatcher hydrates `ctx.row` from the form's
1374
- // coerced values.
1375
- router.post(`${createUrl}/_action/:actionName`, async (req, res) => {
1376
- const user = await pilotiq.resolveUser(req)
1377
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1378
- if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
1379
-
1380
- const actionName = req.params['actionName']!
1381
- const json = wantsJson(req)
1382
- const body = await readFormBody(req)
1383
- const input = parseActionBody(body)
1384
-
1385
- const ctx: SchemaContext = { mode: 'create', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1386
- const elements = await callPageSchema(PageClass, ctx)
1387
- tagActionDispatch(elements, createUrl)
1388
- const target = resolveDispatchTarget(elements, actionName)
1389
- if (!target) {
1390
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1391
- res.status(404)
1392
- return res.send(`Action "${actionName}" not found on ${R.label}`)
1393
- }
1394
-
1395
- const result = await dispatchAction(target.action, {
1396
- ...input,
1397
- request: req,
1398
- user,
1399
- ...(target.rowField ? { rowField: target.rowField } : {}),
1400
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1401
- })
1402
- if (!result.ok) {
1403
- if (json) {
1404
- res.status(result.errors ? 422 : 500)
1405
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
1406
- }
1407
- res.status(500)
1408
- return res.send(result.error)
1409
- }
1410
- if (result.download) return sendDownload(res, result.download)
1411
- const redirect = normalizeRedirect(result.redirect, base) ?? createUrl
1412
- if (json) {
1413
- return res.json({
1414
- ok: true,
1415
- redirect,
1416
- ...(result.notifications ? { notifications: result.notifications } : {}),
1417
- })
1418
- }
1419
- flashNotifications(req, result.notifications)
1420
- return res.redirect(redirect, 303)
1421
- })
1422
- }
1423
-
1424
- // View — GET ${resourceBase}/:id (literal `create` matches first via
1425
- // Hono's literal-over-param routing, so `:id` only catches everything else.)
1426
- if (pages.view) {
1427
- router.get(`${resourceBase}/:id`, async (req, res) => {
1428
- const recordId = req.params['id']!
1429
- // Hono routes both `/create` and `/:id` against this slot; only the
1430
- // literal `create` segment hits the create route. Defensive guard:
1431
- if (recordId === 'create') return // handled by create route
1432
-
1433
- const user = await pilotiq.resolveUser(req)
1434
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1435
- // Load the record once so canView can inspect it. Stub `{ id }`
1436
- // when the resource has no model wired — the user-authored
1437
- // predicate gets to decide what to do with it.
1438
- const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1439
- if (!await checkPolicy(() => R.canView(user, record))) return forbidden(res, wantsJson(req))
1440
-
1441
- const data = await resourceViewData(pilotiq, slug, recordId, req)
1442
- return view('pilotiq.resource-view', data ?? {})
1443
- })
1444
-
1445
- // Delete — POST ${resourceBase}/:id/delete
1446
- router.post(`${resourceBase}/:id/delete`, async (req, res) => {
1447
- const recordId = req.params['id']!
1448
- const json = wantsJson(req)
1449
- const indexUrl = `${resourceBase}`
1450
-
1451
- const user = await pilotiq.resolveUser(req)
1452
- if (!await policyAccess(R, user)) return forbidden(res, json)
1453
- const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1454
- if (!await checkPolicy(() => R.canDelete(user, record))) return forbidden(res, json)
1455
-
1456
- try {
1457
- await R.deleteRecord(recordId)
1458
- } catch (err) {
1459
- const message = err instanceof Error ? err.message : 'Delete failed'
1460
- if (json) {
1461
- res.status(500)
1462
- return res.json({ ok: false, error: message })
1463
- }
1464
- res.status(500)
1465
- return res.send(message)
1466
- }
1467
- if (json) {
1468
- // Build a synthetic deletion notification so the SPA path gets
1469
- // the same toast UX as a JSON-dispatched action handler. The
1470
- // form-method 303 path doesn't have the form-lifecycle toast
1471
- // pipeline, so we surface confirmation here. Plan #13: use
1472
- // "moved to trash" framing on soft-delete resources so users
1473
- // know the row is recoverable.
1474
- const title = R.softDeletes
1475
- ? `${R.labelSingular} moved to trash`
1476
- : `${R.labelSingular} deleted`
1477
- const notifications = [
1478
- { id: `n-delete-${recordId}-${Date.now()}`, type: 'success', title },
1479
- ]
1480
- return res.json({ ok: true, redirect: indexUrl, notifications })
1481
- }
1482
- return res.redirect(indexUrl, 303)
1483
- })
1484
- }
1485
-
1486
- // ─── Plan #13 soft-delete routes (restore / force-delete) ─────
1487
- // Both routes opt-in only when `Resource.softDeletes = true`. They
1488
- // load the target row through `withTrashed()` so the lookup finds
1489
- // currently-trashed records (which the default scope hides). The
1490
- // `restore` route undoes a prior soft-delete; `force-delete`
1491
- // bypasses soft-delete entirely.
1492
- if (R.softDeletes) {
1493
- // Boot-time guard — yell loudly if the rudder ORM model isn't
1494
- // wired up. Keeps "why didn't restore work?" debug sessions
1495
- // short. Pilotiq's flag and rudder's flag are deliberately
1496
- // independent (see plan doc).
1497
- if (!R.model) {
1498
- throw new Error(
1499
- `[Pilotiq] ${R.name}: softDeletes = true requires a Resource.model. Wire one up or unset softDeletes.`,
1500
- )
1501
- }
1502
- if (typeof R.model.restore !== 'function' || typeof R.model.forceDelete !== 'function') {
191
+ const probeColumn = probe.getReorderableColumn()
192
+ if (probeColumn !== undefined) {
193
+ if (!R.model || typeof R.model.reorder !== 'function') {
1503
194
  throw new Error(
1504
- `[Pilotiq] ${R.name}: softDeletes = true but model.restore / model.forceDelete are missing. ` +
1505
- `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
195
+ `[Pilotiq] ${R.name}.table() calls reorderable("${probeColumn}") but the bound model has no reorder(ids) method. ` +
196
+ `Implement \`async reorder(ids)\` on the rudder Model (or remove the .reorderable() call).`,
1506
197
  )
1507
198
  }
1508
-
1509
- const M = R.model
1510
- const pk = (M.primaryKey ?? 'id') as string
1511
-
1512
- // Helper — load a row through `withTrashed` so currently-trashed
1513
- // records resolve. Returns undefined when the lookup misses (route
1514
- // converts to 404).
1515
- const loadTrashable = async (id: string): Promise<unknown> => {
1516
- const q = M.query()
1517
- if (typeof q.withTrashed !== 'function') return M.find(id).catch(() => undefined)
1518
- const result = await q.withTrashed()
1519
- .where(pk, '=', id)
1520
- .paginate(1, 1)
1521
- .catch(() => ({ data: [] as unknown[] }))
1522
- return Array.isArray(result.data) ? result.data[0] : undefined
1523
- }
1524
-
1525
- // Restore — POST ${resourceBase}/:id/restore
1526
- router.post(`${resourceBase}/:id/restore`, async (req, res) => {
1527
- const recordId = req.params['id']!
1528
- const json = wantsJson(req)
1529
- const indexUrl = `${resourceBase}`
1530
-
1531
- const user = await pilotiq.resolveUser(req)
1532
- if (!await policyAccess(R, user)) return forbidden(res, json)
1533
- const record = await loadTrashable(recordId)
1534
- if (!record) {
1535
- res.status(404)
1536
- return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
1537
- }
1538
- if (!await checkPolicy(() => R.canRestore(user, record))) return forbidden(res, json)
1539
-
1540
- try {
1541
- await M.restore!(recordId)
1542
- } catch (err) {
1543
- const message = err instanceof Error ? err.message : 'Restore failed'
1544
- res.status(500)
1545
- return json ? res.json({ ok: false, error: message }) : res.send(message)
1546
- }
1547
-
1548
- if (json) {
1549
- const notifications = [
1550
- { id: `n-restore-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} restored` },
1551
- ]
1552
- return res.json({ ok: true, redirect: indexUrl, notifications })
1553
- }
1554
- return res.redirect(indexUrl, 303)
1555
- })
1556
-
1557
- // Force-delete — POST ${resourceBase}/:id/force-delete
1558
- router.post(`${resourceBase}/:id/force-delete`, async (req, res) => {
1559
- const recordId = req.params['id']!
1560
- const json = wantsJson(req)
1561
- const indexUrl = `${resourceBase}`
1562
-
1563
- const user = await pilotiq.resolveUser(req)
1564
- if (!await policyAccess(R, user)) return forbidden(res, json)
1565
- const record = await loadTrashable(recordId)
1566
- if (!record) {
1567
- res.status(404)
1568
- return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
1569
- }
1570
- if (!await checkPolicy(() => R.canForceDelete(user, record))) return forbidden(res, json)
1571
-
1572
- try {
1573
- await M.forceDelete!(recordId)
1574
- } catch (err) {
1575
- const message = err instanceof Error ? err.message : 'Force-delete failed'
1576
- res.status(500)
1577
- return json ? res.json({ ok: false, error: message }) : res.send(message)
1578
- }
1579
-
1580
- if (json) {
1581
- const notifications = [
1582
- { id: `n-fdelete-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} permanently deleted` },
1583
- ]
1584
- return res.json({ ok: true, redirect: indexUrl, notifications })
1585
- }
1586
- return res.redirect(indexUrl, 303)
1587
- })
199
+ reorderEnabled.set(R.getSlug(), probeColumn)
1588
200
  }
1589
201
 
1590
- // Edit GET ${resourceBase}/:id/edit
1591
- if (pages.edit) {
1592
- const PageClass = pages.edit
1593
-
1594
- router.get(`${resourceBase}/:id/edit`, async (req, res) => {
1595
- const recordId = req.params['id']!
1596
- const user = await pilotiq.resolveUser(req)
1597
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1598
- const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1599
- if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, wantsJson(req))
1600
-
1601
- const data = await resourceEditData(pilotiq, slug, recordId, undefined, req)
1602
- return view('pilotiq.resource-edit', data ?? {})
1603
- })
1604
-
1605
- // Edit — POST ${resourceBase}/:id/edit
1606
- router.post(`${resourceBase}/:id/edit`, async (req, res) => {
1607
- const recordId = req.params['id']!
1608
- const editUrl = `${resourceBase}/${recordId}/edit`
1609
- const body = await readFormBody(req)
1610
- const { values, formId } = splitMeta(body)
1611
- const json = wantsJson(req)
1612
-
1613
- const user = await pilotiq.resolveUser(req)
1614
- if (!await policyAccess(R, user)) return forbidden(res, json)
1615
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1616
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, json)
1617
-
1618
- const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1619
- const elements = await callPageSchema(PageClass, ctx)
1620
- tagFormActions(elements, editUrl)
1621
- const form = selectForm(findForms(elements), formId)
1622
- if (!form) {
1623
- if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
1624
- res.status(404)
1625
- return res.send('No form found on page')
1626
- }
1627
-
1628
- // Try to load the record so validators with cross-field rules see it.
1629
- let record: unknown = undefined
1630
- if (form.getLoadRecord()) {
1631
- try { record = await form.getLoadRecord()!(recordId, { values }) } catch { /* ignore */ }
1632
- }
1633
-
1634
- const result = await dispatchFormSubmit(
1635
- form,
1636
- values,
1637
- {
1638
- values,
1639
- basePath: base,
1640
- ...(record !== undefined ? { record } : {}),
1641
- ...(R.model ? { parentModel: R.model } : {}),
1642
- },
202
+ const hasEditable = (probe.getChildren() ?? [])
203
+ .some(c => c instanceof Column && c.isEditable())
204
+ if (hasEditable) {
205
+ if (!R.model || typeof R.model.update !== 'function') {
206
+ throw new Error(
207
+ `[Pilotiq] ${R.name}.table() declares an editable cell column ` +
208
+ `(TextInputColumn / ToggleColumn / SelectColumn) but the bound ` +
209
+ `model has no update(id, data) method. Set Resource.model = M ` +
210
+ `(rudder ORM convention) or drop the editable column.`,
1643
211
  )
1644
-
1645
- if (!result.ok) {
1646
- if (json) {
1647
- res.status(422)
1648
- return res.json({ ok: false, errors: result.errors })
1649
- }
1650
- const data = await resourceEditData(pilotiq, slug, recordId, { values, errors: result.errors })
1651
- res.status(422)
1652
- return view('pilotiq.resource-edit', data ?? {})
1653
- }
1654
-
1655
- const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
1656
- if (json) {
1657
- return res.json({
1658
- ok: true,
1659
- redirect,
1660
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1661
- })
1662
- }
1663
- flashNotifications(req, result.notifications)
1664
- return res.redirect(redirect, 303)
1665
- })
1666
-
1667
- // Action dispatch — POST ${editUrl}/_action/:actionName
1668
- // Same shape as the create-page _action route. The `:id` segment
1669
- // gates record-aware policy (canEdit per record); row-scoped
1670
- // dispatch reuses the form schema we resolve here for `coerceFormValues`.
1671
- router.post(`${resourceBase}/:id/_action/:actionName`, async (req, res) => {
1672
- const recordId = req.params['id']!
1673
- // Hono routes `/edit` and `/delete` against this slot too — bail
1674
- // out so the dedicated handlers downstream pick them up. The
1675
- // `:actionName` capture catches anything; the explicit guard
1676
- // mirrors the view-route `recordId === 'create'` defensive branch.
1677
- const actionName = req.params['actionName']!
1678
-
1679
- const user = await pilotiq.resolveUser(req)
1680
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
1681
- const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
1682
- if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, wantsJson(req))
1683
-
1684
- const json = wantsJson(req)
1685
- const body = await readFormBody(req)
1686
- const input = parseActionBody(body)
1687
-
1688
- const editUrl = `${resourceBase}/${recordId}/edit`
1689
- const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
1690
- const elements = await callPageSchema(PageClass, ctx)
1691
- tagActionDispatch(elements, editUrl)
1692
- const target = resolveDispatchTarget(elements, actionName)
1693
- if (!target) {
1694
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
1695
- res.status(404)
1696
- return res.send(`Action "${actionName}" not found on ${R.label}`)
1697
- }
1698
-
1699
- const resolveRecord: ResolveRecord | undefined = R.model
1700
- ? (id: string) => findRecord(R, id, { user })
1701
- : undefined
1702
-
1703
- const result = await dispatchAction(target.action, {
1704
- ...input,
1705
- request: req,
1706
- user,
1707
- ...(target.rowField ? { rowField: target.rowField } : {}),
1708
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1709
- }, resolveRecord)
1710
- if (!result.ok) {
1711
- if (json) {
1712
- res.status(result.errors ? 422 : 500)
1713
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
1714
- }
1715
- res.status(500)
1716
- return res.send(result.error)
1717
- }
1718
- if (result.download) return sendDownload(res, result.download)
1719
- const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
1720
- if (json) {
1721
- return res.json({
1722
- ok: true,
1723
- redirect,
1724
- ...(result.notifications ? { notifications: result.notifications } : {}),
1725
- })
1726
- }
1727
- flashNotifications(req, result.notifications)
1728
- return res.redirect(redirect, 303)
1729
- })
1730
- }
1731
-
1732
- // ── Plan #11 relation manager routes ───────────────
1733
- // Per-manager: list, create (GET/POST), edit (GET/POST), delete (POST).
1734
- // Mounted under ${resourceBase}/:id/${rel} — the `:id` segment is the
1735
- // PARENT record id; the `:childId` segment (where present) is the
1736
- // related record's id. Authorization runs in two layers: parent
1737
- // canAccess + canEdit(parent), then manager-scoped can*.
1738
- for (const M of R.relations()) {
1739
- const rel = M.getRelationship()
1740
- const parentBase = `${resourceBase}/:id/${rel}`
1741
-
1742
- // Read the relation type once at registration so the (R, M)-
1743
- // scoped closures all see the same mode without re-reading the
1744
- // relations map per request. `R.model` is asserted by
1745
- // `requireParent` at request time; here it may legitimately be
1746
- // missing during late binding, in which case we fall back to
1747
- // 'hasMany' (the safe default — no special action injection / no
1748
- // factory short-circuiting). See `normalizeRelationMode` for the
1749
- // M2M / polymorphic mappings.
1750
- const relationType = R.model ? getRelationType(R.model, rel) : 'hasMany'
1751
- const mode: RelationMode = normalizeRelationMode(relationType)
1752
-
1753
- // Common policy prelude: load parent, gate access. Returns the
1754
- // parent record on success or a thrown 403/404 response. Returns
1755
- // `undefined` when the route should bail out (response already sent).
1756
- const requireParent = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{ user: unknown; parent: unknown; recordId: string } | undefined> => {
1757
- const recordId = req.params['id']!
1758
- const user = await pilotiq.resolveUser(req)
1759
- if (!await policyAccess(R, user)) { forbidden(res, json); return undefined }
1760
- if (!R.model) {
1761
- res.status(500)
1762
- if (json) res.json({ ok: false, error: `Resource "${R.name}" has relations but no static model` })
1763
- else res.send(`Resource "${R.name}" has relations but no static model`)
1764
- return undefined
1765
- }
1766
- const parent = await findRecord(R, recordId, { user }).catch(() => undefined)
1767
- if (!parent) { res.status(404); if (json) res.json({ ok: false, error: 'Parent not found' }); else res.send('Parent not found'); return undefined }
1768
- if (!await checkPolicy(() => R.canEdit(user, parent))) { forbidden(res, json); return undefined }
1769
- return { user, parent, recordId }
1770
- }
1771
-
1772
- // List — GET ${resourceBase}/:id/${rel}
1773
- // Manager-level canViewAny is enforced inside relationManagerData via
1774
- // safeManagerPolicy (with related-resource fall-through). We just
1775
- // surface the {ok:false,status:403} from the data builder as 403.
1776
- router.get(parentBase, async (req, res) => {
1777
- const json = wantsJson(req)
1778
- const ctx = await requireParent(req, res, json)
1779
- if (!ctx) return
1780
- const data = await relationManagerData(pilotiq, {
1781
- kind: 'relation-list', slug, recordId: ctx.recordId, relationship: rel, query: req.query as Record<string, string>,
1782
- }, req)
1783
- if (data === null) { res.status(404); return res.send('Not found') }
1784
- if ('ok' in data && data.ok === false) return forbidden(res, json)
1785
- return view('pilotiq.relation-list', data)
1786
- })
1787
-
1788
- // Create — GET ${resourceBase}/:id/${rel}/create
1789
- router.get(`${parentBase}/create`, async (req, res) => {
1790
- const json = wantsJson(req)
1791
- const ctx = await requireParent(req, res, json)
1792
- if (!ctx) return
1793
- const data = await relationManagerData(pilotiq, {
1794
- kind: 'relation-create', slug, recordId: ctx.recordId, relationship: rel,
1795
- }, req)
1796
- if (data === null) { res.status(404); return res.send('Not found') }
1797
- if ('ok' in data && data.ok === false) return forbidden(res, json)
1798
- return view('pilotiq.relation-create', data)
1799
- })
1800
-
1801
- // Create submit — POST ${resourceBase}/:id/${rel}/create
1802
- router.post(`${parentBase}/create`, async (req, res) => {
1803
- const json = wantsJson(req)
1804
- const pre = await requireParent(req, res, json)
1805
- if (!pre) return
1806
-
1807
- const Related = findRelatedResource(M, R, cfg)
1808
- if (!Related) {
1809
- res.status(500)
1810
- const msg = `RelationManager ${M.name}: cannot resolve related Resource for create`
1811
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1812
- }
1813
- if (!await safeManagerPolicy(M, 'canCreate', Related, pre.user, pre.parent)) return forbidden(res, json)
1814
-
1815
- const body = await readFormBody(req)
1816
- const { values } = splitMeta(body)
1817
-
1818
- const createUrl = `${parentBase}/create`.replace(':id', pre.recordId)
1819
- const listUrl = parentBase.replace(':id', pre.recordId)
1820
- const form = M.form(Form.make(), {
1821
- basePath: base,
1822
- parentSlug: slug,
1823
- parentId: pre.recordId,
1824
- relationship: rel,
1825
- parentRecord: pre.parent,
1826
- related: Related,
1827
- mode,
1828
- })
1829
- if (Related.model) {
1830
- if (!form.getSave()) form.save(modelSave(Related.model))
1831
- if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
1832
- }
1833
-
1834
- // Polymorphic auto-injection — when the parent's relation entry
1835
- // is `morphMany` / `morphOne`, fill the `{morphName}Id` and
1836
- // `{morphName}Type` columns on the child before persistence.
1837
- // Compose with any user-supplied `mutateDataBeforeCreate` and
1838
- // run AFTER it so morph values overwrite anything the form
1839
- // body or user hook might have set — the parent record is the
1840
- // single source of truth for who owns the new child, and a
1841
- // submitted form field cannot be allowed to tamper with that.
1842
- if (mode === 'morphMany' && R.model) {
1843
- const morphDesc = getMorphRelationDescriptor(R.model, rel)
1844
- if (!morphDesc) {
1845
- res.status(500)
1846
- const msg = `RelationManager ${M.name}: relations[${JSON.stringify(rel)}] reports a polymorphic type but is missing morphName.`
1847
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1848
- }
1849
- const morphPayload = computeMorphPayload(pre.parent, morphDesc)
1850
- const existing = form.getMutateDataBeforeCreate()
1851
- form.mutateDataBeforeCreate(async (data, ctx) => {
1852
- const next = existing ? await existing(data, ctx) : data
1853
- return { ...next, ...morphPayload }
1854
- })
1855
- }
1856
-
1857
- // Stamp parent context onto FormContext so user hooks
1858
- // (mutateDataBeforeCreate, redirectAfterSave, etc.) can default
1859
- // foreign-key columns or build URLs from the parent.
1860
- const formCtx = {
1861
- values,
1862
- basePath: base,
1863
- parent: pre.parent,
1864
- parentId: pre.recordId,
1865
- relationship: rel,
1866
- }
1867
-
1868
- const result = await dispatchFormSubmit(form, values, formCtx)
1869
- if (!result.ok) {
1870
- if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
1871
- const data = await relationManagerData(pilotiq, {
1872
- kind: 'relation-create', slug, recordId: pre.recordId, relationship: rel,
1873
- prefill: { values, errors: result.errors ?? {} },
1874
- }, req)
1875
- res.status(422)
1876
- return view('pilotiq.relation-create', data ?? {})
1877
- }
1878
-
1879
- const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
1880
- if (json) {
1881
- return res.json({
1882
- ok: true, redirect,
1883
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
1884
- })
1885
- }
1886
- flashNotifications(req, result.notifications)
1887
- return res.redirect(redirect, 303)
1888
- })
1889
-
1890
- // View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
1891
- // resources). 5-segment URL. The literal `${parentBase}/create`
1892
- // route is registered above and Hono prefers static segments over
1893
- // wildcards, but the `childId === 'create'` guard belt-and-suspenders
1894
- // against any router that doesn't.
1895
- router.get(`${parentBase}/:childId`, async (req, res) => {
1896
- const json = wantsJson(req)
1897
- const pre = await requireParent(req, res, json)
1898
- if (!pre) return
1899
- const childId = req.params['childId']!
1900
- if (childId === 'create') { res.status(404); return res.send('Not found') }
1901
- const data = await relationManagerData(pilotiq, {
1902
- kind: 'relation-view', slug, recordId: pre.recordId, relationship: rel, childId,
1903
- }, req)
1904
- if (data === null) { res.status(404); return res.send('Not found') }
1905
- if ('ok' in data && data.ok === false) return forbidden(res, json)
1906
- return view('pilotiq.relation-view', data)
1907
- })
1908
-
1909
- // Edit — GET ${resourceBase}/:id/${rel}/:childId/edit
1910
- router.get(`${parentBase}/:childId/edit`, async (req, res) => {
1911
- const json = wantsJson(req)
1912
- const pre = await requireParent(req, res, json)
1913
- if (!pre) return
1914
- const childId = req.params['childId']!
1915
- const data = await relationManagerData(pilotiq, {
1916
- kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1917
- }, req)
1918
- if (data === null) { res.status(404); return res.send('Not found') }
1919
- if ('ok' in data && data.ok === false) return forbidden(res, json)
1920
- return view('pilotiq.relation-edit', data)
1921
- })
1922
-
1923
- // Edit submit — POST ${resourceBase}/:id/${rel}/:childId/edit
1924
- router.post(`${parentBase}/:childId/edit`, async (req, res) => {
1925
- const json = wantsJson(req)
1926
- const pre = await requireParent(req, res, json)
1927
- if (!pre) return
1928
- const childId = req.params['childId']!
1929
-
1930
- const Related = findRelatedResource(M, R, cfg)
1931
- if (!Related?.model) {
1932
- res.status(500)
1933
- const msg = `RelationManager ${M.name}: cannot resolve related Resource for edit`
1934
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
1935
- }
1936
-
1937
- // IDOR + load via the data builder's gating: re-use it to verify
1938
- // the child belongs to this parent, then do the form submit.
1939
- const childCheck = await relationManagerData(pilotiq, {
1940
- kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1941
- }, req)
1942
- if (childCheck === null) { res.status(404); return res.send('Not found') }
1943
- if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
1944
-
1945
- const body = await readFormBody(req)
1946
- const { values } = splitMeta(body)
1947
-
1948
- const editUrl = `${parentBase}/${childId}/edit`.replace(':id', pre.recordId)
1949
- const form = M.form(Form.make(), {
1950
- basePath: base,
1951
- parentSlug: slug,
1952
- parentId: pre.recordId,
1953
- relationship: rel,
1954
- parentRecord: pre.parent,
1955
- related: Related,
1956
- mode,
1957
- })
1958
- if (!form.getSave()) form.save(modelSave(Related.model))
1959
- if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
1960
-
1961
- // Re-load child for FormContext so cross-field validators see it.
1962
- let child: unknown = undefined
1963
- try { child = await findRecord(Related, childId, { user: pre.user }) } catch { /* ignore */ }
1964
- if (!child) { res.status(404); return res.send('Not found') }
1965
-
1966
- // Polymorphic re-stamp on update — same posture as the create
1967
- // path. Re-injecting the morph columns from the live parent
1968
- // record ensures a tampered body (`commentableId=…` /
1969
- // `commentableType=…` posted by an attacker) can't reassign
1970
- // the child to another polymorphic parent. Composed AFTER any
1971
- // user `mutateDataBeforeUpdate` so the framework wins.
1972
- if (mode === 'morphMany' && R.model) {
1973
- const morphDesc = getMorphRelationDescriptor(R.model, rel)
1974
- if (morphDesc) {
1975
- const morphPayload = computeMorphPayload(pre.parent, morphDesc)
1976
- const existing = form.getMutateDataBeforeUpdate()
1977
- form.mutateDataBeforeUpdate(async (data, ctx) => {
1978
- const next = existing ? await existing(data, ctx) : data
1979
- return { ...next, ...morphPayload }
1980
- })
1981
- }
1982
- }
1983
-
1984
- const formCtx = {
1985
- values,
1986
- basePath: base,
1987
- record: child,
1988
- parent: pre.parent,
1989
- parentId: pre.recordId,
1990
- relationship: rel,
1991
- }
1992
-
1993
- const result = await dispatchFormSubmit(form, values, formCtx)
1994
- if (!result.ok) {
1995
- if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
1996
- const data = await relationManagerData(pilotiq, {
1997
- kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
1998
- prefill: { values, errors: result.errors ?? {} },
1999
- }, req)
2000
- res.status(422)
2001
- return view('pilotiq.relation-edit', data ?? {})
2002
- }
2003
-
2004
- const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
2005
- if (json) {
2006
- return res.json({
2007
- ok: true, redirect,
2008
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
2009
- })
2010
- }
2011
- flashNotifications(req, result.notifications)
2012
- return res.redirect(redirect, 303)
2013
- })
2014
-
2015
- // Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
2016
- router.post(`${parentBase}/:childId/delete`, async (req, res) => {
2017
- const json = wantsJson(req)
2018
- const pre = await requireParent(req, res, json)
2019
- if (!pre) return
2020
- const childId = req.params['childId']!
2021
-
2022
- const Related = findRelatedResource(M, R, cfg)
2023
- if (!Related?.model) {
2024
- res.status(500)
2025
- const msg = `RelationManager ${M.name}: cannot resolve related Resource for delete`
2026
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2027
- }
2028
-
2029
- // Anti-IDOR: re-use the data builder's child-belongs check.
2030
- const childCheck = await relationManagerData(pilotiq, {
2031
- kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
2032
- }, req)
2033
- if (childCheck === null) { res.status(404); return res.send('Not found') }
2034
- if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
2035
-
2036
- const child = await findRecord(Related, childId, { user: pre.user }).catch(() => undefined)
2037
- if (!child) { res.status(404); return res.send('Not found') }
2038
-
2039
- if (!await safeManagerPolicy(M, 'canDelete', Related, pre.user, pre.parent, child)) return forbidden(res, json)
2040
-
2041
- const listUrl = parentBase.replace(':id', pre.recordId)
2042
- try {
2043
- await Related.model.delete(childId)
2044
- } catch (err) {
2045
- const message = err instanceof Error ? err.message : 'Delete failed'
2046
- res.status(500)
2047
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2048
- }
2049
-
2050
- if (json) {
2051
- const notifications = [
2052
- { id: `n-rdelete-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} deleted` },
2053
- ]
2054
- return res.json({ ok: true, redirect: listUrl, notifications })
2055
- }
2056
- return res.redirect(listUrl, 303)
2057
- })
2058
-
2059
- // ── Plan #13 polish — relation restore / force-delete ─────
2060
- // Mirror the resource-side soft-delete routes, scoped under the
2061
- // parent record. Both routes opt in only when the related Resource
2062
- // has `softDeletes = true` AND its model carries `restore` /
2063
- // `forceDelete`. Two-layer auth: parent canAccess + canEdit, then
2064
- // manager `canRestore / canForceDelete` (with related-Resource
2065
- // fall-through). IDOR check re-runs the parent's relation query
2066
- // through `withTrashed()` so trashed children still resolve.
2067
- const RelatedForSoft = findRelatedResource(M, R, cfg)
2068
- if (RelatedForSoft?.softDeletes) {
2069
- const RM = RelatedForSoft.model
2070
- if (!RM) {
2071
- throw new Error(
2072
- `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but no model. ` +
2073
- `Wire one up or unset softDeletes.`,
2074
- )
2075
- }
2076
- if (typeof RM.restore !== 'function' || typeof RM.forceDelete !== 'function') {
2077
- throw new Error(
2078
- `[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
2079
- `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
2080
- )
2081
- }
2082
-
2083
- // IDOR-safe load through the parent's relation query, broadened
2084
- // with `withTrashed()` so currently-trashed children resolve.
2085
- // Returns undefined when the child doesn't belong to this parent
2086
- // (under the broadened scope) or the lookup misses.
2087
- const loadTrashableChild = async (parent: unknown, childId: string): Promise<unknown> => {
2088
- if (!R.model) return undefined
2089
- const pk = (RM.primaryKey ?? 'id') as string
2090
- try {
2091
- const q: import('./orm/modelDefaults.js').ModelQuery = R.model.relatedQuery
2092
- ? R.model.relatedQuery(parent, rel)
2093
- : (parent as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(rel)
2094
- const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
2095
- const result = await broadened.where(pk, '=', childId).paginate(1, 1)
2096
- return Array.isArray(result.data) ? result.data[0] : undefined
2097
- } catch {
2098
- return undefined
2099
- }
2100
- }
2101
-
2102
- // Restore — POST ${resourceBase}/:id/${rel}/:childId/restore
2103
- router.post(`${parentBase}/:childId/restore`, async (req, res) => {
2104
- const json = wantsJson(req)
2105
- const pre = await requireParent(req, res, json)
2106
- if (!pre) return
2107
- const childId = req.params['childId']!
2108
-
2109
- const child = await loadTrashableChild(pre.parent, childId)
2110
- if (!child) { res.status(404); return res.send('Not found') }
2111
-
2112
- if (!await safeManagerPolicy(M, 'canRestore', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
2113
-
2114
- const listUrl = parentBase.replace(':id', pre.recordId)
2115
- try {
2116
- await RM.restore!(childId)
2117
- } catch (err) {
2118
- const message = err instanceof Error ? err.message : 'Restore failed'
2119
- res.status(500)
2120
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2121
- }
2122
-
2123
- if (json) {
2124
- const notifications = [
2125
- { id: `n-rrestore-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} restored` },
2126
- ]
2127
- return res.json({ ok: true, redirect: listUrl, notifications })
2128
- }
2129
- return res.redirect(listUrl, 303)
2130
- })
2131
-
2132
- // Force-delete — POST ${resourceBase}/:id/${rel}/:childId/force-delete
2133
- router.post(`${parentBase}/:childId/force-delete`, async (req, res) => {
2134
- const json = wantsJson(req)
2135
- const pre = await requireParent(req, res, json)
2136
- if (!pre) return
2137
- const childId = req.params['childId']!
2138
-
2139
- const child = await loadTrashableChild(pre.parent, childId)
2140
- if (!child) { res.status(404); return res.send('Not found') }
2141
-
2142
- if (!await safeManagerPolicy(M, 'canForceDelete', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
2143
-
2144
- const listUrl = parentBase.replace(':id', pre.recordId)
2145
- try {
2146
- await RM.forceDelete!(childId)
2147
- } catch (err) {
2148
- const message = err instanceof Error ? err.message : 'Force-delete failed'
2149
- res.status(500)
2150
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2151
- }
2152
-
2153
- if (json) {
2154
- const notifications = [
2155
- { id: `n-rforce-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} permanently deleted` },
2156
- ]
2157
- return res.json({ ok: true, redirect: listUrl, notifications })
2158
- }
2159
- return res.redirect(listUrl, 303)
2160
- })
2161
- }
2162
-
2163
- // ── M2M follow-up — manager-scoped action dispatch + detach ─────
2164
- // Two new routes per relation manager. Mounted unconditionally
2165
- // (even on hasMany managers) because handler-style actions are
2166
- // useful beyond M2M — any user-defined `Action.handler(...)` on a
2167
- // manager table needs a place to dispatch. The detach route is
2168
- // M2M-specific but cheap enough to register either way; non-M2M
2169
- // managers' `Action.relationDetach` factories return `visible=false`
2170
- // anyway, so the URL is unreachable in practice.
2171
-
2172
- // Action dispatch — POST ${parentBase}/_action/:actionName
2173
- // Resolves the manager's table elements, finds the named action,
2174
- // and dispatches it with `ctx.relation = { parent, parentId, rel }`
2175
- // so M2M handlers can call `parent.related(rel).attach / detach`.
2176
- // Records hydrate against the related model (the rows visible in
2177
- // the manager's table are related-model records).
2178
- router.post(`${parentBase}/_action/:actionName`, async (req, res) => {
2179
- const json = wantsJson(req)
2180
- const pre = await requireParent(req, res, json)
2181
- if (!pre) return
2182
-
2183
- const Related = findRelatedResource(M, R, cfg)
2184
- const actionName = req.params['actionName']!
2185
- const body = await readFormBody(req)
2186
- const input = parseActionBody(body)
2187
-
2188
- // Rebuild the manager's table so the dispatcher can find the
2189
- // action by name. Pure recreation — same context the page-data
2190
- // builder uses — so factories that close over `ctx` (URL,
2191
- // mode, parent record) see the same shape as at page render.
2192
- const managerCtx = {
2193
- basePath: base,
2194
- parentSlug: slug,
2195
- parentId: pre.recordId,
2196
- relationship: rel,
2197
- parentRecord: pre.parent,
2198
- related: Related,
2199
- mode,
2200
- }
2201
- const table = M.table(Table.make(), managerCtx)
2202
- const elements: import('./schema/Element.js').Element[] = [table]
2203
- // Stamp dispatch URLs so any nested action factories that read
2204
- // `dispatchUrl` (rare — most read it from the meta at render
2205
- // time) still see something sensible.
2206
- const listUrl = parentBase.replace(':id', pre.recordId)
2207
- tagActionDispatch(elements, listUrl)
2208
-
2209
- const target = resolveDispatchTarget(elements, actionName)
2210
- if (!target) {
2211
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
2212
- res.status(404)
2213
- return res.send(`Action "${actionName}" not found on ${M.name}`)
2214
- }
2215
-
2216
- const resolveRecord: ResolveRecord | undefined = Related?.model
2217
- ? (id: string) => Related.model!.find(id)
2218
- : undefined
2219
-
2220
- const result = await dispatchAction(target.action, {
2221
- ...input,
2222
- request: req,
2223
- user: pre.user,
2224
- relation: { parent: pre.parent, parentId: pre.recordId, relationship: rel },
2225
- ...(target.rowField ? { rowField: target.rowField } : {}),
2226
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
2227
- }, resolveRecord)
2228
-
2229
- if (!result.ok) {
2230
- if (json) {
2231
- res.status(result.errors ? 422 : 500)
2232
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
2233
- }
2234
- res.status(500)
2235
- return res.send(result.error)
2236
- }
2237
- const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2238
- if (json) {
2239
- return res.json({
2240
- ok: true,
2241
- redirect,
2242
- ...(result.notifications ? { notifications: result.notifications } : {}),
2243
- })
2244
- }
2245
- flashNotifications(req, result.notifications)
2246
- return res.redirect(redirect, 303)
2247
- })
2248
-
2249
- // Detach — POST ${parentBase}/:childId/_detach
2250
- // Direct row-action target for `Action.relationDetach`. Removes the
2251
- // pivot row only; the related record stays in place. IDOR check:
2252
- // verify the child is currently attached before calling detach so
2253
- // a tampered URL can't probe random ids.
2254
- router.post(`${parentBase}/:childId/_detach`, async (req, res) => {
2255
- const json = wantsJson(req)
2256
- const pre = await requireParent(req, res, json)
2257
- if (!pre) return
2258
- const childId = req.params['childId']!
2259
-
2260
- if (mode !== 'belongsToMany' && mode !== 'morphToMany' && mode !== 'morphedByMany') {
2261
- // Detach is meaningless for hasMany — the user wants `delete`.
2262
- // Surface a clear 404 instead of silently no-op'ing.
2263
- res.status(404)
2264
- const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
2265
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2266
- }
2267
-
2268
- // Manager-only canDetach: pivot ops don't fall through to the
2269
- // related Resource. We don't have the related child loaded yet —
2270
- // pass `undefined` for the per-record arg; canDetach gates on
2271
- // (user, parent) by default and only sees `record` when a
2272
- // manager has explicitly overridden with a per-row predicate.
2273
- // Authors who need per-row gating can detect undefined and either
2274
- // load the child themselves or short-circuit.
2275
- // Two distinct accessors are needed under the real
2276
- // `@rudderjs/orm`:
2277
- // - `parent.related(rel)` returns a deferred QueryBuilder
2278
- // with `where / paginate` (IDOR read-side check).
2279
- // - `parent[rel]()` returns the pivot-mutation accessor with
2280
- // `attach / detach / sync` (write-side).
2281
- // Test stubs may collapse both onto the same `parent.related(rel)`
2282
- // shape — handle that fallback so existing tests keep passing.
2283
- let child: unknown = undefined
2284
- const readSide = (pre.parent as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
2285
- ?.related?.(rel)
2286
- if (!readSide) {
2287
- res.status(500)
2288
- const msg = `Parent.related("${rel}") missing — wrong relation type or ORM version?`
2289
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2290
- }
2291
- try {
2292
- // IDOR: confirm the child is currently attached.
2293
- if (typeof readSide.paginate === 'function') {
2294
- const Related = findRelatedResource(M, R, cfg)
2295
- const pk = Related?.model ? getPrimaryKey(Related.model) : 'id'
2296
- const out = await (readSide as unknown as { where: (col: string, op: string, val: unknown) => { paginate: (p: number, pp: number) => Promise<{ data: unknown[] }> } }).where(pk, '=', childId).paginate(1, 1)
2297
- child = Array.isArray(out.data) ? out.data[0] : undefined
2298
- }
2299
- } catch {
2300
- // fall through; null child means we couldn't verify — safer to 404
2301
- }
2302
- if (child === undefined) { res.status(404); return res.send('Not found') }
2303
-
2304
- if (!await safeManagerPolicy(M, 'canDetach', undefined, pre.user, pre.parent, child)) return forbidden(res, json)
2305
-
2306
- // Real ORM: `parent[rel]()` returns the pivot accessor. Test
2307
- // stubs: `parent.related(rel)` may carry `detach` directly.
2308
- // Try the prototype-installed instance method first, then fall
2309
- // back to the read-side shape.
2310
- let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
2311
- const inst = (pre.parent as Record<string, unknown>)[rel]
2312
- if (typeof inst === 'function') {
2313
- try {
2314
- const out = (inst as () => unknown).call(pre.parent) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
2315
- if (out && typeof out.detach === 'function') writeAccessor = out
2316
- } catch { /* fall through to legacy shape */ }
2317
- }
2318
- if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
2319
- writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
2320
- }
2321
- if (!writeAccessor) {
2322
- res.status(500)
2323
- const msg = `Pivot accessor missing on ${rel} — wrong relation type or ORM version?`
2324
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2325
- }
2326
-
2327
- try {
2328
- await writeAccessor.detach!([childId])
2329
- } catch (err) {
2330
- const message = err instanceof Error ? err.message : 'Detach failed'
2331
- res.status(500)
2332
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2333
- }
2334
-
2335
- const listUrl = parentBase.replace(':id', pre.recordId)
2336
- if (json) {
2337
- const notifications = [
2338
- { id: `n-rdetach-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} detached` },
2339
- ]
2340
- return res.json({ ok: true, redirect: listUrl, notifications })
2341
- }
2342
- return res.redirect(listUrl, 303)
2343
- })
2344
-
2345
- // ── Phase B nested relation routes ──────────────────
2346
- // For each manager N declared under M.relations(), mount the
2347
- // depth-2 list/create/view/edit/delete handlers. Auth + chain
2348
- // IDOR are centralized in `nestedRelationManagerData` — route
2349
- // bodies dispatch the data builder and unwrap the tagged
2350
- // {ok:false,status:403} / null shapes. Surface area mirrors
2351
- // Phase A: no M2M attach/detach, no soft-delete restore on
2352
- // nested managers in v1 (open follow-ups if a consumer asks).
2353
- for (const N of M.relations()) {
2354
- const nestedRel = N.getRelationship()
2355
- const nestedBase = `${parentBase}/:childId/${nestedRel}`
2356
-
2357
- // Build a `chain` tuple from the URL params for relayed calls
2358
- // into `relationManagerData`. The childId of the *outer* manager
2359
- // is the recordId of the leaf step.
2360
- const buildChain = (id: string, childId1: string): [{ recordId: string; relationship: string }, { recordId: string; relationship: string }] => [
2361
- { recordId: id, relationship: rel },
2362
- { recordId: childId1, relationship: nestedRel },
2363
- ]
2364
-
2365
- // ── List ──
2366
- router.get(nestedBase, async (req, res) => {
2367
- const json = wantsJson(req)
2368
- const id = req.params['id']!
2369
- const childId1 = req.params['childId']!
2370
- const data = await relationManagerData(pilotiq, {
2371
- kind: 'nested-relation-list', slug,
2372
- chain: buildChain(id, childId1),
2373
- query: req.query as Record<string, string>,
2374
- }, req)
2375
- if (data === null) { res.status(404); return res.send('Not found') }
2376
- if ('ok' in data && data.ok === false) return forbidden(res, json)
2377
- return view('pilotiq.nested-relation-list', data)
2378
- })
2379
-
2380
- // ── Create (GET) ──
2381
- router.get(`${nestedBase}/create`, async (req, res) => {
2382
- const json = wantsJson(req)
2383
- const id = req.params['id']!
2384
- const childId1 = req.params['childId']!
2385
- const data = await relationManagerData(pilotiq, {
2386
- kind: 'nested-relation-create', slug,
2387
- chain: buildChain(id, childId1),
2388
- }, req)
2389
- if (data === null) { res.status(404); return res.send('Not found') }
2390
- if ('ok' in data && data.ok === false) return forbidden(res, json)
2391
- return view('pilotiq.nested-relation-create', data)
2392
- })
2393
-
2394
- // ── Create (POST) ──
2395
- router.post(`${nestedBase}/create`, async (req, res) => {
2396
- const json = wantsJson(req)
2397
- const id = req.params['id']!
2398
- const childId1 = req.params['childId']!
2399
- // Run the chain walk once to verify auth + IDOR + load child1.
2400
- // Any failure returns the same tagged shape we serve on GET.
2401
- const pre = await relationManagerData(pilotiq, {
2402
- kind: 'nested-relation-create', slug,
2403
- chain: buildChain(id, childId1),
2404
- }, req)
2405
- if (pre === null) { res.status(404); return res.send('Not found') }
2406
- if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2407
-
2408
- // Re-resolve the leaf manager's bits for form submit. We need
2409
- // the leaf parent record (`child1`) and the related class for
2410
- // save/loadRecord wiring. Reuse `findRelatedResource` against
2411
- // the chain walk's intermediate Resource (Related1).
2412
- const Related1 = findRelatedResource(M, R, cfg)
2413
- if (!Related1) {
2414
- res.status(500)
2415
- const msg = `Nested manager ${N.name}: cannot resolve middle Resource for create`
2416
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2417
- }
2418
- const Related2 = findRelatedResource(N, Related1, cfg)
2419
- if (!Related2?.model) {
2420
- res.status(500)
2421
- const msg = `Nested manager ${N.name}: cannot resolve related Resource for create`
2422
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2423
- }
2424
- const user = await pilotiq.resolveUser(req)
2425
- const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2426
- if (!child1) { res.status(404); return res.send('Not found') }
2427
-
2428
- const body = await readFormBody(req)
2429
- const { values } = splitMeta(body)
2430
-
2431
- const createUrl = `${nestedBase}/create`.replace(':id', id).replace(':childId', childId1)
2432
- const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
2433
-
2434
- const nestedMode: RelationMode = Related1.model
2435
- ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
2436
- : 'hasMany'
2437
-
2438
- const form = N.form(Form.make(), {
2439
- basePath: base,
2440
- parentSlug: slug,
2441
- parentId: childId1,
2442
- relationship: nestedRel,
2443
- parentRecord: child1,
2444
- related: Related2,
2445
- mode: nestedMode,
2446
- chain: [{ slug, recordId: id, relationship: rel }],
2447
- })
2448
- if (Related2.model) {
2449
- if (!form.getSave()) form.save(modelSave(Related2.model))
2450
- if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
2451
- }
2452
-
2453
- // Polymorphic morph-column auto-injection mirrors the depth-1
2454
- // create handler — uses Related1 (the leaf parent's owner) as
2455
- // the morph source on the leaf relation.
2456
- if (nestedMode === 'morphMany' && Related1.model) {
2457
- const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
2458
- if (!morphDesc) {
2459
- res.status(500)
2460
- const msg = `Nested manager ${N.name}: relations[${JSON.stringify(nestedRel)}] reports a polymorphic type but is missing morphName.`
2461
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2462
- }
2463
- const morphPayload = computeMorphPayload(child1, morphDesc)
2464
- const existing = form.getMutateDataBeforeCreate()
2465
- form.mutateDataBeforeCreate(async (data, ctx) => {
2466
- const next = existing ? await existing(data, ctx) : data
2467
- return { ...next, ...morphPayload }
2468
- })
2469
- }
2470
-
2471
- const formCtx = {
2472
- values,
2473
- basePath: base,
2474
- parent: child1,
2475
- parentId: childId1,
2476
- relationship: nestedRel,
2477
- }
2478
-
2479
- const result = await dispatchFormSubmit(form, values, formCtx)
2480
- if (!result.ok) {
2481
- if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
2482
- const data = await relationManagerData(pilotiq, {
2483
- kind: 'nested-relation-create', slug,
2484
- chain: buildChain(id, childId1),
2485
- prefill: { values, errors: result.errors ?? {} },
2486
- }, req)
2487
- res.status(422)
2488
- return view('pilotiq.nested-relation-create', data ?? {})
2489
- }
2490
-
2491
- const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2492
- if (json) {
2493
- return res.json({
2494
- ok: true, redirect,
2495
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
2496
- })
2497
- }
2498
- flashNotifications(req, result.notifications)
2499
- return res.redirect(redirect, 303)
2500
- // `createUrl` referenced above is intentionally unused on
2501
- // success — kept for parity with the depth-1 path's prefill
2502
- // re-render shape if a future caller wants to redirect to it.
2503
- void createUrl
2504
- })
2505
-
2506
- // ── View ──
2507
- router.get(`${nestedBase}/:childId2`, async (req, res) => {
2508
- const json = wantsJson(req)
2509
- const id = req.params['id']!
2510
- const childId1 = req.params['childId']!
2511
- const childId2 = req.params['childId2']!
2512
- if (childId2 === 'create') { res.status(404); return res.send('Not found') }
2513
- const data = await relationManagerData(pilotiq, {
2514
- kind: 'nested-relation-view', slug,
2515
- chain: buildChain(id, childId1),
2516
- childId: childId2,
2517
- }, req)
2518
- if (data === null) { res.status(404); return res.send('Not found') }
2519
- if ('ok' in data && data.ok === false) return forbidden(res, json)
2520
- return view('pilotiq.nested-relation-view', data)
2521
- })
2522
-
2523
- // ── Edit (GET) ──
2524
- router.get(`${nestedBase}/:childId2/edit`, async (req, res) => {
2525
- const json = wantsJson(req)
2526
- const id = req.params['id']!
2527
- const childId1 = req.params['childId']!
2528
- const childId2 = req.params['childId2']!
2529
- const data = await relationManagerData(pilotiq, {
2530
- kind: 'nested-relation-edit', slug,
2531
- chain: buildChain(id, childId1),
2532
- childId: childId2,
2533
- }, req)
2534
- if (data === null) { res.status(404); return res.send('Not found') }
2535
- if ('ok' in data && data.ok === false) return forbidden(res, json)
2536
- return view('pilotiq.nested-relation-edit', data)
2537
- })
2538
-
2539
- // ── Edit (POST) ──
2540
- router.post(`${nestedBase}/:childId2/edit`, async (req, res) => {
2541
- const json = wantsJson(req)
2542
- const id = req.params['id']!
2543
- const childId1 = req.params['childId']!
2544
- const childId2 = req.params['childId2']!
2545
-
2546
- // Replay the chain to verify auth, IDOR, load child1+child2.
2547
- const pre = await relationManagerData(pilotiq, {
2548
- kind: 'nested-relation-edit', slug,
2549
- chain: buildChain(id, childId1),
2550
- childId: childId2,
2551
- }, req)
2552
- if (pre === null) { res.status(404); return res.send('Not found') }
2553
- if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2554
-
2555
- const Related1 = findRelatedResource(M, R, cfg)
2556
- if (!Related1) {
2557
- res.status(500)
2558
- const msg = `Nested manager ${N.name}: cannot resolve middle Resource for edit`
2559
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2560
- }
2561
- const Related2 = findRelatedResource(N, Related1, cfg)
2562
- if (!Related2?.model) {
2563
- res.status(500)
2564
- const msg = `Nested manager ${N.name}: cannot resolve related Resource for edit`
2565
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2566
- }
2567
-
2568
- const user = await pilotiq.resolveUser(req)
2569
- const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2570
- if (!child1) { res.status(404); return res.send('Not found') }
2571
- const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
2572
- if (!child2) { res.status(404); return res.send('Not found') }
2573
-
2574
- const body = await readFormBody(req)
2575
- const { values } = splitMeta(body)
2576
-
2577
- const editUrl = `${nestedBase}/${childId2}/edit`.replace(':id', id).replace(':childId', childId1)
2578
-
2579
- const nestedMode: RelationMode = Related1.model
2580
- ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
2581
- : 'hasMany'
2582
-
2583
- const form = N.form(Form.make(), {
2584
- basePath: base,
2585
- parentSlug: slug,
2586
- parentId: childId1,
2587
- relationship: nestedRel,
2588
- parentRecord: child1,
2589
- related: Related2,
2590
- mode: nestedMode,
2591
- chain: [{ slug, recordId: id, relationship: rel }],
2592
- })
2593
- if (!form.getSave()) form.save(modelSave(Related2.model))
2594
- if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
2595
-
2596
- if (nestedMode === 'morphMany' && Related1.model) {
2597
- const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
2598
- if (morphDesc) {
2599
- const morphPayload = computeMorphPayload(child1, morphDesc)
2600
- const existing = form.getMutateDataBeforeUpdate()
2601
- form.mutateDataBeforeUpdate(async (data, ctx) => {
2602
- const next = existing ? await existing(data, ctx) : data
2603
- return { ...next, ...morphPayload }
2604
- })
2605
- }
2606
- }
2607
-
2608
- const formCtx = {
2609
- values,
2610
- basePath: base,
2611
- record: child2,
2612
- parent: child1,
2613
- parentId: childId1,
2614
- relationship: nestedRel,
2615
- }
2616
-
2617
- const result = await dispatchFormSubmit(form, values, formCtx)
2618
- if (!result.ok) {
2619
- if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
2620
- const data = await relationManagerData(pilotiq, {
2621
- kind: 'nested-relation-edit', slug,
2622
- chain: buildChain(id, childId1),
2623
- childId: childId2,
2624
- prefill: { values, errors: result.errors ?? {} },
2625
- }, req)
2626
- res.status(422)
2627
- return view('pilotiq.nested-relation-edit', data ?? {})
2628
- }
2629
-
2630
- const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
2631
- if (json) {
2632
- return res.json({
2633
- ok: true, redirect,
2634
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
2635
- })
2636
- }
2637
- flashNotifications(req, result.notifications)
2638
- return res.redirect(redirect, 303)
2639
- })
2640
-
2641
- // ── Delete ──
2642
- router.post(`${nestedBase}/:childId2/delete`, async (req, res) => {
2643
- const json = wantsJson(req)
2644
- const id = req.params['id']!
2645
- const childId1 = req.params['childId']!
2646
- const childId2 = req.params['childId2']!
2647
-
2648
- // Replay the chain to verify auth + IDOR + load child2.
2649
- // We piggy-back on the edit scope's checks (canEdit on the
2650
- // leaf manager — same gate the depth-1 delete uses today via
2651
- // the relation-edit scope).
2652
- const pre = await relationManagerData(pilotiq, {
2653
- kind: 'nested-relation-edit', slug,
2654
- chain: buildChain(id, childId1),
2655
- childId: childId2,
2656
- }, req)
2657
- if (pre === null) { res.status(404); return res.send('Not found') }
2658
- if ('ok' in pre && pre.ok === false) return forbidden(res, json)
2659
-
2660
- const Related1 = findRelatedResource(M, R, cfg)
2661
- if (!Related1) {
2662
- res.status(500)
2663
- const msg = `Nested manager ${N.name}: cannot resolve middle Resource for delete`
2664
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2665
- }
2666
- const Related2 = findRelatedResource(N, Related1, cfg)
2667
- if (!Related2?.model) {
2668
- res.status(500)
2669
- const msg = `Nested manager ${N.name}: cannot resolve related Resource for delete`
2670
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2671
- }
2672
-
2673
- const user = await pilotiq.resolveUser(req)
2674
- const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
2675
- if (!child1) { res.status(404); return res.send('Not found') }
2676
- const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
2677
- if (!child2) { res.status(404); return res.send('Not found') }
2678
-
2679
- if (!await safeManagerPolicy(N, 'canDelete', Related2, user, child1, child2)) return forbidden(res, json)
2680
-
2681
- const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
2682
- try {
2683
- await Related2.model.delete(childId2)
2684
- } catch (err) {
2685
- const message = err instanceof Error ? err.message : 'Delete failed'
2686
- res.status(500)
2687
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2688
- }
2689
-
2690
- if (json) {
2691
- const notifications = [
2692
- { id: `n-nrdelete-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} deleted` },
2693
- ]
2694
- return res.json({ ok: true, redirect: listUrl, notifications })
2695
- }
2696
- return res.redirect(listUrl, 303)
2697
- })
2698
-
2699
- // ── Phase B follow-up — nested action / detach / soft-delete ──
2700
- // Mirror the depth-1 manager surface (`_action`, `_detach`,
2701
- // `restore`, `force-delete`) under the nested manager. Auth +
2702
- // chain IDOR centralized in `resolveRelationChain`; each route
2703
- // layers its own scope-specific gate (canDetach / canRestore /
2704
- // canForceDelete; the action route mirrors depth-1 by not adding
2705
- // an extra manager-level gate beyond the chain walk).
2706
- const nestedChainSlug = slug
2707
- const requireNestedChain = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{
2708
- user: unknown
2709
- resolved: ResolvedChain
2710
- parentId: string
2711
- child1Id: string
2712
- } | undefined> => {
2713
- const id = req.params['id']!
2714
- const child1Id = req.params['childId']!
2715
- const user = await pilotiq.resolveUser(req)
2716
- const resolved = await resolveRelationChain(pilotiq, {
2717
- kind: 'nested-relation-list',
2718
- slug: nestedChainSlug,
2719
- chain: [
2720
- { recordId: id, relationship: rel },
2721
- { recordId: child1Id, relationship: nestedRel },
2722
- ],
2723
- }, user)
2724
- if (resolved === null) { res.status(404); res.send('Not found'); return undefined }
2725
- if ('ok' in resolved) { forbidden(res, json); return undefined }
2726
- return { user, resolved, parentId: id, child1Id }
2727
- }
2728
-
2729
- // Listing URL (filled per request — `:id` / `:childId` get baked
2730
- // in once the params are known). All four routes redirect here
2731
- // on success so users land back on the nested-relation list.
2732
- const nestedListUrlFor = (id: string, child1Id: string): string =>
2733
- nestedBase.replace(':id', id).replace(':childId', child1Id)
2734
-
2735
- // ── Action dispatch — POST ${nestedBase}/_action/:actionName ──
2736
- // Resolves N's table elements, finds the named action, dispatches
2737
- // it with `ctx.relation = { parent: child1, parentId, rel }` so
2738
- // M2M handlers on the nested manager can call accessor methods.
2739
- // Handler-style actions are useful on hasMany too — mounted
2740
- // unconditionally.
2741
- router.post(`${nestedBase}/_action/:actionName`, async (req, res) => {
2742
- const json = wantsJson(req)
2743
- const pre = await requireNestedChain(req, res, json)
2744
- if (!pre) return
2745
- const { resolved } = pre
2746
- const { Related1, child1, M2, Related2, child2Mode } = resolved
2747
-
2748
- const actionName = req.params['actionName']!
2749
- const body = await readFormBody(req)
2750
- const input = parseActionBody(body)
2751
-
2752
- // Manager ctx for N — same shape `nestedManagerCtx` builds for
2753
- // the data-builder side, so factories that close over `ctx`
2754
- // (URL templates, mode-aware visibility) see the same view as
2755
- // at page render.
2756
- const nestedManagerCtxObj = {
2757
- basePath: base,
2758
- parentSlug: resolved.R.getSlug(),
2759
- parentId: pre.child1Id, // immediate parent of N = child1
2760
- relationship: nestedRel,
2761
- parentRecord: child1,
2762
- related: Related2,
2763
- mode: child2Mode,
2764
- chain: [{
2765
- slug: resolved.R.getSlug(),
2766
- recordId: pre.parentId,
2767
- relationship: rel,
2768
- }],
2769
- }
2770
- const table = M2.table(Table.make(), nestedManagerCtxObj)
2771
- const elements: import('./schema/Element.js').Element[] = [table]
2772
- const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2773
- tagActionDispatch(elements, listUrl)
2774
-
2775
- const target = resolveDispatchTarget(elements, actionName)
2776
- if (!target) {
2777
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
2778
- res.status(404)
2779
- return res.send(`Action "${actionName}" not found on ${M2.name}`)
2780
- }
2781
-
2782
- const resolveRecord: ResolveRecord | undefined = Related2?.model
2783
- ? (id: string) => Related2.model!.find(id)
2784
- : undefined
2785
-
2786
- const result = await dispatchAction(target.action, {
2787
- ...input,
2788
- request: req,
2789
- user: pre.user,
2790
- relation: { parent: child1, parentId: pre.child1Id, relationship: nestedRel },
2791
- ...(target.rowField ? { rowField: target.rowField } : {}),
2792
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
2793
- }, resolveRecord)
2794
-
2795
- if (!result.ok) {
2796
- if (json) {
2797
- res.status(result.errors ? 422 : 500)
2798
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
2799
- }
2800
- res.status(500)
2801
- return res.send(result.error)
2802
- }
2803
- const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
2804
- if (json) {
2805
- return res.json({
2806
- ok: true,
2807
- redirect,
2808
- ...(result.notifications ? { notifications: result.notifications } : {}),
2809
- })
2810
- }
2811
- flashNotifications(req, result.notifications)
2812
- return res.redirect(redirect, 303)
2813
- })
2814
-
2815
- // ── Detach — POST ${nestedBase}/:childId2/_detach ──
2816
- // M2M-only direct row-detach. IDOR-checks the grandchild against
2817
- // child1.related(nestedRel), then calls accessor.detach. Mirrors
2818
- // the depth-1 detach route at line 1955.
2819
- router.post(`${nestedBase}/:childId2/_detach`, async (req, res) => {
2820
- const json = wantsJson(req)
2821
- const pre = await requireNestedChain(req, res, json)
2822
- if (!pre) return
2823
- const childId2 = req.params['childId2']!
2824
- const { resolved } = pre
2825
- const { Related1, child1, M2, Related2, child2Mode } = resolved
2826
-
2827
- if (child2Mode !== 'belongsToMany' && child2Mode !== 'morphToMany' && child2Mode !== 'morphedByMany') {
2828
- res.status(404)
2829
- const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
2830
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2831
- }
2832
-
2833
- // IDOR: confirm child2 is currently attached to child1 under
2834
- // nestedRel. Read-side accessor (`child1.related(nestedRel)`)
2835
- // returns a deferred QueryBuilder; we never bypass it.
2836
- const readSide = (child1 as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
2837
- ?.related?.(nestedRel)
2838
- if (!readSide) {
2839
- res.status(500)
2840
- const msg = `child1.related("${nestedRel}") missing — wrong relation type or ORM version?`
2841
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2842
- }
2843
- let child2: unknown = undefined
2844
- try {
2845
- if (typeof readSide.paginate === 'function') {
2846
- const pk = Related2?.model ? getPrimaryKey(Related2.model) : 'id'
2847
- const out = await (readSide as unknown as { where: (col: string, op: string, val: unknown) => { paginate: (p: number, pp: number) => Promise<{ data: unknown[] }> } }).where(pk, '=', childId2).paginate(1, 1)
2848
- child2 = Array.isArray(out.data) ? out.data[0] : undefined
2849
- }
2850
- } catch { /* fall through */ }
2851
- if (child2 === undefined) { res.status(404); return res.send('Not found') }
2852
-
2853
- if (!await safeManagerPolicy(M2, 'canDetach', Related2, pre.user, child1, child2)) return forbidden(res, json)
2854
-
2855
- // Real ORM: child1[nestedRel]() returns the pivot accessor
2856
- // with attach/detach/sync. Test stubs may collapse onto
2857
- // `child1.related(nestedRel)` — try both.
2858
- let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
2859
- const inst = (child1 as Record<string, unknown>)[nestedRel]
2860
- if (typeof inst === 'function') {
2861
- try {
2862
- const out = (inst as () => unknown).call(child1) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
2863
- if (out && typeof out.detach === 'function') writeAccessor = out
2864
- } catch { /* fall through */ }
2865
- }
2866
- if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
2867
- writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
2868
- }
2869
- if (!writeAccessor) {
2870
- res.status(500)
2871
- const msg = `Pivot accessor missing on ${nestedRel} — wrong relation type or ORM version?`
2872
- return json ? res.json({ ok: false, error: msg }) : res.send(msg)
2873
- }
2874
-
2875
- try {
2876
- await writeAccessor.detach!([childId2])
2877
- } catch (err) {
2878
- const message = err instanceof Error ? err.message : 'Detach failed'
2879
- res.status(500)
2880
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2881
- }
2882
-
2883
- const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2884
- if (json) {
2885
- const notifications = [
2886
- { id: `n-nrdetach-${childId2}-${Date.now()}`, type: 'success', title: `${M2.getLabelSingular()} detached` },
2887
- ]
2888
- return res.json({ ok: true, redirect: listUrl, notifications })
2889
- }
2890
- return res.redirect(listUrl, 303)
2891
- })
2892
-
2893
- // ── Soft-delete: restore + force-delete ───────────────────────
2894
- // Opt in only when Related2 has `softDeletes = true` AND its
2895
- // model carries `restore` / `forceDelete`. Mirrors the depth-1
2896
- // routes at line 1804+. IDOR runs against child1.related(nestedRel)
2897
- // broadened with `withTrashed()` so trashed grandchildren resolve.
2898
- const Related1ForSoft = findRelatedResource(M, R, cfg)
2899
- const Related2ForSoft = Related1ForSoft ? findRelatedResource(N, Related1ForSoft, cfg) : undefined
2900
- if (Related2ForSoft?.softDeletes) {
2901
- const RM2 = Related2ForSoft.model
2902
- if (!RM2) {
2903
- throw new Error(
2904
- `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but no model. ` +
2905
- `Wire one up or unset softDeletes.`,
2906
- )
2907
- }
2908
- if (typeof RM2.restore !== 'function' || typeof RM2.forceDelete !== 'function') {
2909
- throw new Error(
2910
- `[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
2911
- `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
2912
- )
2913
- }
2914
-
2915
- // Like the depth-1 helper: load the grandchild via the parent's
2916
- // relation query, broadened with `withTrashed()`. Returns
2917
- // undefined when the lookup misses or the grandchild doesn't
2918
- // belong to child1 under nestedRel.
2919
- const loadTrashableGrandchild = async (parentChild: unknown, child2Id: string): Promise<unknown> => {
2920
- const pk = (RM2.primaryKey ?? 'id') as string
2921
- try {
2922
- const q: import('./orm/modelDefaults.js').ModelQuery = (parentChild as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(nestedRel)
2923
- const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
2924
- const result = await broadened.where(pk, '=', child2Id).paginate(1, 1)
2925
- return Array.isArray(result.data) ? result.data[0] : undefined
2926
- } catch {
2927
- return undefined
2928
- }
2929
- }
2930
-
2931
- // Restore — POST ${nestedBase}/:childId2/restore
2932
- router.post(`${nestedBase}/:childId2/restore`, async (req, res) => {
2933
- const json = wantsJson(req)
2934
- const pre = await requireNestedChain(req, res, json)
2935
- if (!pre) return
2936
- const childId2 = req.params['childId2']!
2937
- const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
2938
- if (!child2) { res.status(404); return res.send('Not found') }
2939
-
2940
- if (!await safeManagerPolicy(N, 'canRestore', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
2941
-
2942
- const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2943
- try {
2944
- await RM2.restore!(childId2)
2945
- } catch (err) {
2946
- const message = err instanceof Error ? err.message : 'Restore failed'
2947
- res.status(500)
2948
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2949
- }
2950
-
2951
- if (json) {
2952
- const notifications = [
2953
- { id: `n-nrrestore-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} restored` },
2954
- ]
2955
- return res.json({ ok: true, redirect: listUrl, notifications })
2956
- }
2957
- return res.redirect(listUrl, 303)
2958
- })
2959
-
2960
- // Force-delete — POST ${nestedBase}/:childId2/force-delete
2961
- router.post(`${nestedBase}/:childId2/force-delete`, async (req, res) => {
2962
- const json = wantsJson(req)
2963
- const pre = await requireNestedChain(req, res, json)
2964
- if (!pre) return
2965
- const childId2 = req.params['childId2']!
2966
- const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
2967
- if (!child2) { res.status(404); return res.send('Not found') }
2968
-
2969
- if (!await safeManagerPolicy(N, 'canForceDelete', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
2970
-
2971
- const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
2972
- try {
2973
- await RM2.forceDelete!(childId2)
2974
- } catch (err) {
2975
- const message = err instanceof Error ? err.message : 'Force-delete failed'
2976
- res.status(500)
2977
- return json ? res.json({ ok: false, error: message }) : res.send(message)
2978
- }
2979
-
2980
- if (json) {
2981
- const notifications = [
2982
- { id: `n-nrforce-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} permanently deleted` },
2983
- ]
2984
- return res.json({ ok: true, redirect: listUrl, notifications })
2985
- }
2986
- return res.redirect(listUrl, 303)
2987
- })
2988
- }
2989
212
  }
213
+ editableEnabled.add(R.getSlug())
2990
214
  }
215
+ }
2991
216
 
2992
- // ── Record sub-pages ───────────────────────────────
2993
- // `${resourceBase}/:id/${subSlug}` same URL slot as a relation
2994
- // manager's `relationship`, distinguished by registry: the data
2995
- // builder tries the relation lookup first, then falls through to
2996
- // the record sub-page map. Boot-time validation ensures the slugs
2997
- // don't collide.
2998
- for (const [subPageSlug, SubPage] of Object.entries(R.getRecordPages())) {
2999
- void SubPage // referenced inside `resourceRecordPageData` via the registry; the local binding is captured for closure-stable types only.
3000
- const recordPageUrl = `${resourceBase}/:id/${subPageSlug}`
3001
- router.get(recordPageUrl, async (req, res) => {
3002
- const user = await pilotiq.resolveUser(req)
3003
- if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
3004
- const recordId = req.params['id']!
3005
- const data = await resourceRecordPageData(pilotiq, slug, recordId, subPageSlug, req)
3006
- if (data === null) { res.status(404); return res.send('Not found') }
3007
- if ('ok' in data && data.ok === false) return forbidden(res, wantsJson(req))
3008
- return view('pilotiq.slug', data)
3009
- })
3010
- }
217
+ // ── Panel-level sibling routes ────────────────────────
218
+ // Dashboard, _uploads, _widget, _search, _notifications.
219
+ // Pulled out 2026-05-12 (Phase 2 of the routes.ts split).
220
+ registerPanelRoutes(router, pilotiq, base)
221
+
222
+ // ── Resource routes ───────────────────────────────────
223
+ // List / view / create / edit / delete + soft-delete / actions /
224
+ // widgets / deferred-table / reorder / per-row editable cells / the
225
+ // four form-state companion endpoints / record sub-pages. Each
226
+ // Resource also fans out into its registered relation managers
227
+ // (depth-1 + depth-2). Pulled out 2026-05-12 (Phase 5 of the
228
+ // routes.ts split).
229
+ for (const R of cfg.resources) {
230
+ registerResourceRoutes(router, pilotiq, R, base, {
231
+ reorderable: reorderEnabled.has(R.getSlug()),
232
+ editable: editableEnabled.has(R.getSlug()),
233
+ })
3011
234
  }
3012
235
 
3013
236
  // ── Globals (singletons — 2-segment, no /:id) ────────
237
+ // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
3014
238
  for (const G of cfg.globals) {
3015
- const slug = G.getSlug()
3016
- const editUrl = globalBasePath(base, G)
3017
- const pages = G.resolvePages()
3018
-
3019
- if (pages.edit) {
3020
- const PageClass = pages.edit
3021
-
3022
- // Plan #5 partial-resolve endpoint for the global's edit form.
3023
- // POST ${editUrl}/_form/:formId/state
3024
- router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
3025
- const user = await pilotiq.resolveUser(req)
3026
- if (!await policyAccess(G, user)) return forbidden(res, true)
3027
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
3028
- const formId = req.params['formId']!
3029
- return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
3030
- })
3031
-
3032
- // Plan #8 wizard step-validate endpoint for the global's edit form.
3033
- router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
3034
- const user = await pilotiq.resolveUser(req)
3035
- if (!await policyAccess(G, user)) return forbidden(res, true)
3036
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
3037
- const formId = req.params['formId']!
3038
- return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
3039
- })
3040
-
3041
- // Async-mention endpoint for the global's edit form.
3042
- router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
3043
- const user = await pilotiq.resolveUser(req)
3044
- if (!await policyAccess(G, user)) return forbidden(res, true)
3045
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
3046
- const formId = req.params['formId']!
3047
- return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
3048
- })
3049
-
3050
- // SelectField inline-create modal endpoint for the global's edit form.
3051
- router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
3052
- const user = await pilotiq.resolveUser(req)
3053
- if (!await policyAccess(G, user)) return forbidden(res, true)
3054
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
3055
- const formId = req.params['formId']!
3056
- const fieldName = req.params['fieldName']!
3057
- return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
3058
- })
3059
-
3060
- router.get(editUrl, async (req, res) => {
3061
- const user = await pilotiq.resolveUser(req)
3062
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
3063
- // Globals carry their record on the singleton form's `loadRecord`;
3064
- // we don't pre-load here — pass a stub so canEdit's signature is
3065
- // honored, and let user code decide whether to consult it.
3066
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
3067
- const data = await globalEditData(pilotiq, slug, undefined, req)
3068
- return view('pilotiq.slug', data ?? {})
3069
- })
3070
-
3071
- router.post(editUrl, async (req, res) => {
3072
- const body = await readFormBody(req)
3073
- const { values, formId } = splitMeta(body)
3074
- const json = wantsJson(req)
3075
-
3076
- const user = await pilotiq.resolveUser(req)
3077
- if (!await policyAccess(G, user)) return forbidden(res, json)
3078
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
3079
-
3080
- const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
3081
- const elements = await callPageSchema(PageClass, ctx)
3082
- tagFormActions(elements, editUrl)
3083
- const form = selectForm(findForms(elements), formId)
3084
- if (!form) {
3085
- if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
3086
- res.status(404)
3087
- return res.send('No form found on page')
3088
- }
3089
-
3090
- // Provide the existing singleton record to the lifecycle context
3091
- // so cross-field validators / mutateData see prior state.
3092
- let record: unknown = undefined
3093
- if (form.getLoadRecord()) {
3094
- try { record = await form.getLoadRecord()!('', { values }) } catch { /* ignore */ }
3095
- }
3096
-
3097
- const result = await dispatchFormSubmit(
3098
- form,
3099
- values,
3100
- record !== undefined ? { values, record, basePath: base } : { values, basePath: base },
3101
- )
3102
-
3103
- if (!result.ok) {
3104
- if (json) {
3105
- res.status(422)
3106
- return res.json({ ok: false, errors: result.errors })
3107
- }
3108
- const data = await globalEditData(pilotiq, slug, { values, errors: result.errors })
3109
- res.status(422)
3110
- return view('pilotiq.slug', data ?? {})
3111
- }
3112
-
3113
- const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
3114
- if (json) {
3115
- return res.json({
3116
- ok: true,
3117
- redirect,
3118
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
3119
- })
3120
- }
3121
- flashNotifications(req, result.notifications)
3122
- return res.redirect(redirect, 303)
3123
- })
3124
- }
3125
-
3126
- // Optional view page when the user opts in via pages().view
3127
- if (pages.view) {
3128
- router.get(`${editUrl}/view`, async (req, res) => {
3129
- const user = await pilotiq.resolveUser(req)
3130
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
3131
- if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
3132
- const data = await globalViewData(pilotiq, slug, req)
3133
- return view('pilotiq.resource-view', data ?? {})
3134
- })
3135
- }
239
+ registerGlobalRoutes(router, pilotiq, G, base)
3136
240
  }
3137
241
 
3138
242
  // ── Custom pages (2-segment, slug route) ──────────────
243
+ // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
3139
244
  for (const PageClass of cfg.pages) {
3140
- // Plan #15 — the dashboard page lives at `${base}` (handled by the
3141
- // dashboard route above), so skip it here to avoid registering a
3142
- // duplicate `${pageUrl}` route or a broken `${base}/` (when
3143
- // `slug = ''`).
245
+ // The dashboard page lives at `${base}` (panel routes handle it);
246
+ // skip it here so we don't register a duplicate `${pageUrl}` route
247
+ // or a broken `${base}/` (when `slug = ''`).
3144
248
  if (cfg.dashboardPage === PageClass) continue
3145
-
3146
- const pageSlug = PageClass.getSlug()
3147
- const pageUrl = pageBasePath(base, PageClass)
3148
-
3149
- // Plan #15 — per-page widget polling endpoint. Mirrors the
3150
- // panel-scope `${base}/_widget/:id` but resolves the custom page's
3151
- // schema instead of the dashboard's.
3152
- router.post(`${pageUrl}/_widget/:id`, async (req, res) => {
3153
- const user = await pilotiq.resolveUser(req)
3154
- if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3155
- return handleWidgetData(req, res, pilotiq, { kind: 'page', pageSlug }, req.params['id']!)
3156
- })
3157
-
3158
- // Plan #5 partial-resolve endpoint for custom pages with reactive forms.
3159
- // POST ${base}/${pageSlug}/_form/:formId/state
3160
- router.post(`${pageUrl}/_form/:formId/state`, async (req, res) => {
3161
- const user = await pilotiq.resolveUser(req)
3162
- if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3163
- const formId = req.params['formId']!
3164
- return handleFormState(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3165
- })
3166
-
3167
- // Plan #8 wizard step-validate endpoint for custom pages.
3168
- router.post(`${pageUrl}/_form/:formId/wizard`, async (req, res) => {
3169
- const user = await pilotiq.resolveUser(req)
3170
- if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3171
- const formId = req.params['formId']!
3172
- return handleFormWizard(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3173
- })
3174
-
3175
- // Async-mention endpoint for custom pages.
3176
- router.post(`${pageUrl}/_form/:formId/mentions`, async (req, res) => {
3177
- const user = await pilotiq.resolveUser(req)
3178
- if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3179
- const formId = req.params['formId']!
3180
- return handleFormMentions(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
3181
- })
3182
-
3183
- // SelectField inline-create modal endpoint for custom pages.
3184
- router.post(`${pageUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
3185
- const user = await pilotiq.resolveUser(req)
3186
- if (!await policyAccess(PageClass, user)) return forbidden(res, true)
3187
- const formId = req.params['formId']!
3188
- const fieldName = req.params['fieldName']!
3189
- return handleFormCreateOption(req, res, pilotiq, { kind: 'page', pageSlug }, formId, fieldName)
3190
- })
3191
-
3192
- router.get(pageUrl, async (req, res) => {
3193
- const user = await pilotiq.resolveUser(req)
3194
- if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
3195
- const data = await customPageData(pilotiq, pageSlug, req)
3196
- return view('pilotiq.slug', data ?? {})
3197
- })
3198
-
3199
- // Action dispatch — POST ${base}/${pageSlug}/_action/:actionName
3200
- router.post(`${pageUrl}/_action/:actionName`, async (req, res) => {
3201
- const user = await pilotiq.resolveUser(req)
3202
- if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
3203
-
3204
- const actionName = req.params['actionName']!
3205
- const json = wantsJson(req)
3206
- const body = await readFormBody(req)
3207
- const input = parseActionBody(body)
3208
-
3209
- const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
3210
- const elements = await callPageSchema(PageClass, ctx)
3211
- tagActionDispatch(elements, pageUrl)
3212
- const target = resolveDispatchTarget(elements, actionName)
3213
- if (!target) {
3214
- if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
3215
- res.status(404)
3216
- return res.send(`Action "${actionName}" not found on page`)
3217
- }
3218
-
3219
- const result = await dispatchAction(target.action, {
3220
- ...input,
3221
- request: req,
3222
- user,
3223
- ...(target.rowField ? { rowField: target.rowField } : {}),
3224
- ...(target.formSchema ? { formSchema: target.formSchema } : {}),
3225
- })
3226
- if (!result.ok) {
3227
- if (json) {
3228
- res.status(result.errors ? 422 : 500)
3229
- return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
3230
- }
3231
- res.status(500)
3232
- return res.send(result.error)
3233
- }
3234
- if (result.download) return sendDownload(res, result.download)
3235
- const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
3236
- if (json) {
3237
- return res.json({
3238
- ok: true,
3239
- redirect,
3240
- ...(result.notifications ? { notifications: result.notifications } : {}),
3241
- })
3242
- }
3243
- flashNotifications(req, result.notifications)
3244
- return res.redirect(redirect, 303)
3245
- })
3246
-
3247
- // Custom pages can also accept submits when their schema includes a Form.
3248
- router.post(pageUrl, async (req, res) => {
3249
- const body = await readFormBody(req)
3250
- const { values, formId } = splitMeta(body)
3251
- const json = wantsJson(req)
3252
-
3253
- const user = await pilotiq.resolveUser(req)
3254
- if (!await policyAccess(PageClass, user)) return forbidden(res, json)
3255
-
3256
- const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
3257
- const elements = await callPageSchema(PageClass, ctx)
3258
- tagFormActions(elements, pageUrl)
3259
- const form = selectForm(findForms(elements), formId)
3260
- if (!form) {
3261
- if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
3262
- res.status(404)
3263
- return res.send('No form found on page')
3264
- }
3265
-
3266
- const result = await dispatchFormSubmit(form, values, { values, basePath: base })
3267
-
3268
- if (!result.ok) {
3269
- if (json) {
3270
- res.status(422)
3271
- return res.json({ ok: false, errors: result.errors })
3272
- }
3273
- form.withValues(values).withErrors(result.errors)
3274
- const schemaData = await resolveSchema(elements, ctx)
3275
- res.status(422)
3276
- return view('pilotiq.slug', {
3277
- pageType: 'page',
3278
- panel: await panelInfo(pilotiq, req),
3279
- page: PageClass.toMeta(),
3280
- schemaData,
3281
- basePath: base,
3282
- layout: cfg.layout,
3283
- hasErrors: true,
3284
- })
3285
- }
3286
-
3287
- const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
3288
- if (json) {
3289
- return res.json({
3290
- ok: true,
3291
- redirect,
3292
- ...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
3293
- })
3294
- }
3295
- flashNotifications(req, result.notifications)
3296
- return res.redirect(redirect, 303)
3297
- })
249
+ registerCustomPageRoutes(router, pilotiq, PageClass, base)
3298
250
  }
3299
251
 
3300
252
  // ── Theme editor ──────────────────────────────────────
253
+ // Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
3301
254
  if (cfg.themeEditor) {
3302
- router.get(`${base}/theme`, async (req) => {
3303
- return view('pilotiq.theme', {
3304
- panel: await panelInfo(pilotiq, req),
3305
- basePath: base,
3306
- layout: cfg.layout,
3307
- themeConfig: pilotiq.getMergedTheme() ?? {},
3308
- })
3309
- })
3310
-
3311
- router.get(`${base}/api/_theme`, async (_req, res) => {
3312
- let overrides: Partial<ThemeConfig> | null = null
3313
- try {
3314
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3315
- const prisma = app().make('prisma') as any
3316
- const slug = `${cfg.name}__theme`
3317
- const row = await prisma.panelGlobal.findUnique({ where: { slug } })
3318
- if (row?.data) {
3319
- const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
3320
- overrides = migrateThemeOverrides(raw)
3321
- }
3322
- } catch { /* no DB or no table — that's fine */ }
3323
-
3324
- return res.json({
3325
- config: cfg.theme ?? {},
3326
- overrides: overrides ?? {},
3327
- options: {
3328
- presets: Object.keys(presets),
3329
- baseColors: Object.keys(baseColors),
3330
- themeColors: ['base', ...HUE_NAMES],
3331
- chartColors: ['base', ...HUE_NAMES],
3332
- radii: Object.keys(radiusMap),
3333
- iconLibraries: ['lucide', 'tabler', 'phosphor', 'remix'],
3334
- },
3335
- })
3336
- })
3337
-
3338
- router.put(`${base}/api/_theme`, async (req, res) => {
3339
- try {
3340
- const overrides = req.body as Partial<ThemeConfig>
3341
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3342
- const prisma = app().make('prisma') as any
3343
- const slug = `${cfg.name}__theme`
3344
-
3345
- await prisma.panelGlobal.upsert({
3346
- where: { slug },
3347
- update: { data: JSON.stringify(overrides) },
3348
- create: { slug, data: JSON.stringify(overrides) },
3349
- })
3350
-
3351
- pilotiq.setThemeOverrides(overrides)
3352
- return res.json({ ok: true })
3353
- } catch (e) {
3354
- return res.status(500).json({ message: e instanceof Error ? e.message : 'Failed to save theme' })
3355
- }
3356
- })
3357
-
3358
- router.delete(`${base}/api/_theme`, async (_req, res) => {
3359
- try {
3360
- const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
3361
- const prisma = app().make('prisma') as any
3362
- const slug = `${cfg.name}__theme`
3363
- await prisma.panelGlobal.delete({ where: { slug } }).catch(() => {})
3364
- pilotiq.setThemeOverrides(undefined)
3365
- } catch { /* ignore */ }
3366
- return res.json({ ok: true })
3367
- })
255
+ registerThemeRoutes(router, pilotiq, base)
3368
256
  }
3369
257
 
3370
258
  // Plugin route hook — runs AFTER all core routes register so plugins