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