@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
@@ -0,0 +1,1239 @@
1
+ import { view } from '@rudderjs/view';
2
+ import { Form } from '../elements/Form.js';
3
+ import { dispatchFormSubmit } from '../elements/dispatchForm.js';
4
+ import { dispatchAction, parseActionBody } from '../elements/dispatchAction.js';
5
+ import { Table } from '../elements/Table.js';
6
+ import { tagActionDispatch, relationManagerData, findRelatedResource, safeManagerPolicy, resolveRelationChain, } from '../pageData.js';
7
+ import { normalizeRelationMode, } from '../RelationManager.js';
8
+ import { modelSave, modelLoadRecord, findRecord, getPrimaryKey, getRelationType, getMorphRelationDescriptor, computeMorphPayload, } from '../orm/modelDefaults.js';
9
+ import { resourceBasePath } from '../clusterPaths.js';
10
+ import { wantsJson, readFormBody, normalizeRedirect, splitMeta, forbidden, checkPolicy, resolveDispatchTarget, sendActionResult, sendMutationSuccess, sendRedirectResponse, findInQueryWithTrashed, loadAccessGated, } from './helpers.js';
11
+ /**
12
+ * Register the relation manager routes for one Resource — every
13
+ * relation declared via `R.relations()` mounts a depth-1 strip
14
+ * (list / create / view / edit / delete / restore / force-delete /
15
+ * `_action` / `_detach`); each nested `M.relations()` entry mounts a
16
+ * depth-2 strip with the same shape under a `nestedBase` prefix.
17
+ *
18
+ * Authorization is two-layered: parent `canAccess + canEdit` runs first,
19
+ * then manager-scoped `canX` (with fall-through to the related Resource
20
+ * via `safeManagerPolicy`). Reserved-token / depth-3 / morphTo-no-target
21
+ * guards run at boot in the host barrel (`routes.ts`), not here.
22
+ *
23
+ * Pulled out of `registerPilotiqRoutes` in 2026-05-12 (Phase 4 of the
24
+ * routes.ts split). Called once per `cfg.resources` entry from
25
+ * `registerResourceRoutes`.
26
+ */
27
+ export function registerRelationRoutes(router, pilotiq, R, base) {
28
+ const cfg = pilotiq.getConfig();
29
+ const slug = R.getSlug();
30
+ const resourceBase = resourceBasePath(base, R);
31
+ for (const M of R.relations()) {
32
+ const rel = M.getRelationship();
33
+ const parentBase = `${resourceBase}/:id/${rel}`;
34
+ // Read the relation type once at registration so the (R, M)-
35
+ // scoped closures all see the same mode without re-reading the
36
+ // relations map per request. `R.model` is asserted by
37
+ // `requireParent` at request time; here it may legitimately be
38
+ // missing during late binding, in which case we fall back to
39
+ // 'hasMany' (the safe default — no special action injection / no
40
+ // factory short-circuiting). See `normalizeRelationMode` for the
41
+ // M2M / polymorphic mappings.
42
+ const relationType = R.model ? getRelationType(R.model, rel) : 'hasMany';
43
+ const mode = normalizeRelationMode(relationType);
44
+ // Hoist out of the per-handler closures: `findRelatedResource` does
45
+ // a linear scan over `cfg.resources` and the result is invariant
46
+ // per (R, M) pair. Compute once at registration so each request
47
+ // skips the scan.
48
+ const Related = findRelatedResource(M, R, cfg);
49
+ // Common policy prelude: load parent, gate access. Returns the
50
+ // parent record on success or a thrown 403/404 response. Returns
51
+ // `undefined` when the route should bail out (response already sent).
52
+ const requireParent = async (req, res, json) => {
53
+ const recordId = req.params['id'];
54
+ if (!R.model) {
55
+ // Async resolve is still needed to keep error-shape identical.
56
+ await pilotiq.resolveUser(req);
57
+ res.status(500);
58
+ if (json)
59
+ res.json({ ok: false, error: `Resource "${R.name}" has relations but no static model` });
60
+ else
61
+ res.send(`Resource "${R.name}" has relations but no static model`);
62
+ return undefined;
63
+ }
64
+ const user = await pilotiq.resolveUser(req);
65
+ // Parallelize the access probe and the parent load — both depend
66
+ // only on `user`, and the access check + parent existence check
67
+ // happen on parallel round-trips instead of sequential.
68
+ const { access, record: parent } = await loadAccessGated(R, recordId, user);
69
+ if (!access) {
70
+ forbidden(res, json);
71
+ return undefined;
72
+ }
73
+ if (!parent) {
74
+ res.status(404);
75
+ if (json)
76
+ res.json({ ok: false, error: 'Parent not found' });
77
+ else
78
+ res.send('Parent not found');
79
+ return undefined;
80
+ }
81
+ if (!await checkPolicy(() => R.canEdit(user, parent))) {
82
+ forbidden(res, json);
83
+ return undefined;
84
+ }
85
+ return { user, parent, recordId };
86
+ };
87
+ // List — GET ${resourceBase}/:id/${rel}
88
+ // Manager-level canViewAny is enforced inside relationManagerData via
89
+ // safeManagerPolicy (with related-resource fall-through). We just
90
+ // surface the {ok:false,status:403} from the data builder as 403.
91
+ router.get(parentBase, async (req, res) => {
92
+ const json = wantsJson(req);
93
+ const ctx = await requireParent(req, res, json);
94
+ if (!ctx)
95
+ return;
96
+ const data = await relationManagerData(pilotiq, {
97
+ kind: 'relation-list', slug, recordId: ctx.recordId, relationship: rel, query: req.query,
98
+ }, req);
99
+ if (data === null) {
100
+ res.status(404);
101
+ return res.send('Not found');
102
+ }
103
+ if ('ok' in data && data.ok === false)
104
+ return forbidden(res, json);
105
+ return view('pilotiq.relation-list', data);
106
+ });
107
+ // Create — GET ${resourceBase}/:id/${rel}/create
108
+ router.get(`${parentBase}/create`, async (req, res) => {
109
+ const json = wantsJson(req);
110
+ const ctx = await requireParent(req, res, json);
111
+ if (!ctx)
112
+ return;
113
+ const data = await relationManagerData(pilotiq, {
114
+ kind: 'relation-create', slug, recordId: ctx.recordId, relationship: rel,
115
+ }, req);
116
+ if (data === null) {
117
+ res.status(404);
118
+ return res.send('Not found');
119
+ }
120
+ if ('ok' in data && data.ok === false)
121
+ return forbidden(res, json);
122
+ return view('pilotiq.relation-create', data);
123
+ });
124
+ // Create submit — POST ${resourceBase}/:id/${rel}/create
125
+ router.post(`${parentBase}/create`, async (req, res) => {
126
+ const json = wantsJson(req);
127
+ const pre = await requireParent(req, res, json);
128
+ if (!pre)
129
+ return;
130
+ if (!Related) {
131
+ res.status(500);
132
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for create`;
133
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
134
+ }
135
+ if (!await safeManagerPolicy(M, 'canCreate', Related, pre.user, pre.parent))
136
+ return forbidden(res, json);
137
+ const body = await readFormBody(req);
138
+ const { values } = splitMeta(body);
139
+ const createUrl = `${parentBase}/create`.replace(':id', pre.recordId);
140
+ const listUrl = parentBase.replace(':id', pre.recordId);
141
+ const form = M.form(Form.make(), {
142
+ basePath: base,
143
+ parentSlug: slug,
144
+ parentId: pre.recordId,
145
+ relationship: rel,
146
+ parentRecord: pre.parent,
147
+ related: Related,
148
+ mode,
149
+ });
150
+ if (Related.model) {
151
+ if (!form.getSave())
152
+ form.save(modelSave(Related.model));
153
+ if (!form.getLoadRecord())
154
+ form.loadRecord(modelLoadRecord(Related));
155
+ }
156
+ // Polymorphic auto-injection — when the parent's relation entry
157
+ // is `morphMany` / `morphOne`, fill the `{morphName}Id` and
158
+ // `{morphName}Type` columns on the child before persistence.
159
+ // Compose with any user-supplied `mutateDataBeforeCreate` and
160
+ // run AFTER it so morph values overwrite anything the form
161
+ // body or user hook might have set — the parent record is the
162
+ // single source of truth for who owns the new child, and a
163
+ // submitted form field cannot be allowed to tamper with that.
164
+ if (mode === 'morphMany' && R.model) {
165
+ const morphDesc = getMorphRelationDescriptor(R.model, rel);
166
+ if (!morphDesc) {
167
+ res.status(500);
168
+ const msg = `RelationManager ${M.name}: relations[${JSON.stringify(rel)}] reports a polymorphic type but is missing morphName.`;
169
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
170
+ }
171
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc);
172
+ const existing = form.getMutateDataBeforeCreate();
173
+ form.mutateDataBeforeCreate(async (data, ctx) => {
174
+ const next = existing ? await existing(data, ctx) : data;
175
+ return { ...next, ...morphPayload };
176
+ });
177
+ }
178
+ // Stamp parent context onto FormContext so user hooks
179
+ // (mutateDataBeforeCreate, redirectAfterSave, etc.) can default
180
+ // foreign-key columns or build URLs from the parent.
181
+ const formCtx = {
182
+ values,
183
+ basePath: base,
184
+ parent: pre.parent,
185
+ parentId: pre.recordId,
186
+ relationship: rel,
187
+ };
188
+ const result = await dispatchFormSubmit(form, values, formCtx);
189
+ if (!result.ok) {
190
+ if (json) {
191
+ res.status(422);
192
+ return res.json({ ok: false, errors: result.errors });
193
+ }
194
+ const data = await relationManagerData(pilotiq, {
195
+ kind: 'relation-create', slug, recordId: pre.recordId, relationship: rel,
196
+ prefill: { values, errors: result.errors ?? {} },
197
+ }, req);
198
+ res.status(422);
199
+ return view('pilotiq.relation-create', data ?? {});
200
+ }
201
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl;
202
+ return sendRedirectResponse(req, res, json, redirect, result.notifications);
203
+ });
204
+ // View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
205
+ // resources). 5-segment URL. The literal `${parentBase}/create`
206
+ // route is registered above and Hono prefers static segments over
207
+ // wildcards, but the `childId === 'create'` guard belt-and-suspenders
208
+ // against any router that doesn't.
209
+ router.get(`${parentBase}/:childId`, async (req, res) => {
210
+ const json = wantsJson(req);
211
+ const pre = await requireParent(req, res, json);
212
+ if (!pre)
213
+ return;
214
+ const childId = req.params['childId'];
215
+ if (childId === 'create') {
216
+ res.status(404);
217
+ return res.send('Not found');
218
+ }
219
+ const data = await relationManagerData(pilotiq, {
220
+ kind: 'relation-view', slug, recordId: pre.recordId, relationship: rel, childId,
221
+ }, req);
222
+ if (data === null) {
223
+ res.status(404);
224
+ return res.send('Not found');
225
+ }
226
+ if ('ok' in data && data.ok === false)
227
+ return forbidden(res, json);
228
+ return view('pilotiq.relation-view', data);
229
+ });
230
+ // Edit — GET ${resourceBase}/:id/${rel}/:childId/edit
231
+ router.get(`${parentBase}/:childId/edit`, async (req, res) => {
232
+ const json = wantsJson(req);
233
+ const pre = await requireParent(req, res, json);
234
+ if (!pre)
235
+ return;
236
+ const childId = req.params['childId'];
237
+ const data = await relationManagerData(pilotiq, {
238
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
239
+ }, req);
240
+ if (data === null) {
241
+ res.status(404);
242
+ return res.send('Not found');
243
+ }
244
+ if ('ok' in data && data.ok === false)
245
+ return forbidden(res, json);
246
+ return view('pilotiq.relation-edit', data);
247
+ });
248
+ // Edit submit — POST ${resourceBase}/:id/${rel}/:childId/edit
249
+ router.post(`${parentBase}/:childId/edit`, async (req, res) => {
250
+ const json = wantsJson(req);
251
+ const pre = await requireParent(req, res, json);
252
+ if (!pre)
253
+ return;
254
+ const childId = req.params['childId'];
255
+ if (!Related?.model) {
256
+ res.status(500);
257
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for edit`;
258
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
259
+ }
260
+ // IDOR + load via the data builder's gating: re-use it to verify
261
+ // the child belongs to this parent, then do the form submit.
262
+ const childCheck = await relationManagerData(pilotiq, {
263
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
264
+ }, req);
265
+ if (childCheck === null) {
266
+ res.status(404);
267
+ return res.send('Not found');
268
+ }
269
+ if ('ok' in childCheck && childCheck.ok === false)
270
+ return forbidden(res, json);
271
+ const body = await readFormBody(req);
272
+ const { values } = splitMeta(body);
273
+ const editUrl = `${parentBase}/${childId}/edit`.replace(':id', pre.recordId);
274
+ const form = M.form(Form.make(), {
275
+ basePath: base,
276
+ parentSlug: slug,
277
+ parentId: pre.recordId,
278
+ relationship: rel,
279
+ parentRecord: pre.parent,
280
+ related: Related,
281
+ mode,
282
+ });
283
+ if (!form.getSave())
284
+ form.save(modelSave(Related.model));
285
+ if (!form.getLoadRecord())
286
+ form.loadRecord(modelLoadRecord(Related));
287
+ // Re-load child for FormContext so cross-field validators see it.
288
+ let child = undefined;
289
+ try {
290
+ child = await findRecord(Related, childId, { user: pre.user });
291
+ }
292
+ catch { /* ignore */ }
293
+ if (!child) {
294
+ res.status(404);
295
+ return res.send('Not found');
296
+ }
297
+ // Polymorphic re-stamp on update — same posture as the create
298
+ // path. Re-injecting the morph columns from the live parent
299
+ // record ensures a tampered body (`commentableId=…` /
300
+ // `commentableType=…` posted by an attacker) can't reassign
301
+ // the child to another polymorphic parent. Composed AFTER any
302
+ // user `mutateDataBeforeUpdate` so the framework wins.
303
+ if (mode === 'morphMany' && R.model) {
304
+ const morphDesc = getMorphRelationDescriptor(R.model, rel);
305
+ if (morphDesc) {
306
+ const morphPayload = computeMorphPayload(pre.parent, morphDesc);
307
+ const existing = form.getMutateDataBeforeUpdate();
308
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
309
+ const next = existing ? await existing(data, ctx) : data;
310
+ return { ...next, ...morphPayload };
311
+ });
312
+ }
313
+ }
314
+ const formCtx = {
315
+ values,
316
+ basePath: base,
317
+ record: child,
318
+ parent: pre.parent,
319
+ parentId: pre.recordId,
320
+ relationship: rel,
321
+ };
322
+ const result = await dispatchFormSubmit(form, values, formCtx);
323
+ if (!result.ok) {
324
+ if (json) {
325
+ res.status(422);
326
+ return res.json({ ok: false, errors: result.errors });
327
+ }
328
+ const data = await relationManagerData(pilotiq, {
329
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
330
+ prefill: { values, errors: result.errors ?? {} },
331
+ }, req);
332
+ res.status(422);
333
+ return view('pilotiq.relation-edit', data ?? {});
334
+ }
335
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl;
336
+ return sendRedirectResponse(req, res, json, redirect, result.notifications);
337
+ });
338
+ // Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
339
+ router.post(`${parentBase}/:childId/delete`, async (req, res) => {
340
+ const json = wantsJson(req);
341
+ const pre = await requireParent(req, res, json);
342
+ if (!pre)
343
+ return;
344
+ const childId = req.params['childId'];
345
+ if (!Related?.model) {
346
+ res.status(500);
347
+ const msg = `RelationManager ${M.name}: cannot resolve related Resource for delete`;
348
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
349
+ }
350
+ // Anti-IDOR: re-use the data builder's child-belongs check.
351
+ const childCheck = await relationManagerData(pilotiq, {
352
+ kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
353
+ }, req);
354
+ if (childCheck === null) {
355
+ res.status(404);
356
+ return res.send('Not found');
357
+ }
358
+ if ('ok' in childCheck && childCheck.ok === false)
359
+ return forbidden(res, json);
360
+ const child = await findRecord(Related, childId, { user: pre.user }).catch(() => undefined);
361
+ if (!child) {
362
+ res.status(404);
363
+ return res.send('Not found');
364
+ }
365
+ if (!await safeManagerPolicy(M, 'canDelete', Related, pre.user, pre.parent, child))
366
+ return forbidden(res, json);
367
+ const listUrl = parentBase.replace(':id', pre.recordId);
368
+ try {
369
+ await Related.model.delete(childId);
370
+ }
371
+ catch (err) {
372
+ const message = err instanceof Error ? err.message : 'Delete failed';
373
+ res.status(500);
374
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
375
+ }
376
+ return sendMutationSuccess(req, res, json, {
377
+ id: childId, kind: 'rdelete', title: `${M.getLabelSingular()} deleted`, redirect: listUrl,
378
+ });
379
+ });
380
+ // ── Plan #13 polish — relation restore / force-delete ─────
381
+ // Mirror the resource-side soft-delete routes, scoped under the
382
+ // parent record. Both routes opt in only when the related Resource
383
+ // has `softDeletes = true` AND its model carries `restore` /
384
+ // `forceDelete`. Two-layer auth: parent canAccess + canEdit, then
385
+ // manager `canRestore / canForceDelete` (with related-Resource
386
+ // fall-through). IDOR check re-runs the parent's relation query
387
+ // through `withTrashed()` so trashed children still resolve.
388
+ const RelatedForSoft = Related;
389
+ if (RelatedForSoft?.softDeletes) {
390
+ const RM = RelatedForSoft.model;
391
+ if (!RM) {
392
+ throw new Error(`[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but no model. ` +
393
+ `Wire one up or unset softDeletes.`);
394
+ }
395
+ if (typeof RM.restore !== 'function' || typeof RM.forceDelete !== 'function') {
396
+ throw new Error(`[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
397
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`);
398
+ }
399
+ // IDOR-safe load through the parent's relation query, broadened
400
+ // with `withTrashed()` so currently-trashed children resolve.
401
+ // Returns undefined when the child doesn't belong to this parent
402
+ // (under the broadened scope) or the lookup misses.
403
+ const loadTrashableChild = async (parent, childId) => {
404
+ if (!R.model)
405
+ return undefined;
406
+ const pk = (RM.primaryKey ?? 'id');
407
+ const q = R.model.relatedQuery
408
+ ? R.model.relatedQuery(parent, rel)
409
+ : parent.related(rel);
410
+ return findInQueryWithTrashed(q, pk, childId);
411
+ };
412
+ // Restore — POST ${resourceBase}/:id/${rel}/:childId/restore
413
+ router.post(`${parentBase}/:childId/restore`, async (req, res) => {
414
+ const json = wantsJson(req);
415
+ const pre = await requireParent(req, res, json);
416
+ if (!pre)
417
+ return;
418
+ const childId = req.params['childId'];
419
+ const child = await loadTrashableChild(pre.parent, childId);
420
+ if (!child) {
421
+ res.status(404);
422
+ return res.send('Not found');
423
+ }
424
+ if (!await safeManagerPolicy(M, 'canRestore', RelatedForSoft, pre.user, pre.parent, child))
425
+ return forbidden(res, json);
426
+ const listUrl = parentBase.replace(':id', pre.recordId);
427
+ try {
428
+ await RM.restore(childId);
429
+ }
430
+ catch (err) {
431
+ const message = err instanceof Error ? err.message : 'Restore failed';
432
+ res.status(500);
433
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
434
+ }
435
+ return sendMutationSuccess(req, res, json, {
436
+ id: childId, kind: 'rrestore', title: `${M.getLabelSingular()} restored`, redirect: listUrl,
437
+ });
438
+ });
439
+ // Force-delete — POST ${resourceBase}/:id/${rel}/:childId/force-delete
440
+ router.post(`${parentBase}/:childId/force-delete`, async (req, res) => {
441
+ const json = wantsJson(req);
442
+ const pre = await requireParent(req, res, json);
443
+ if (!pre)
444
+ return;
445
+ const childId = req.params['childId'];
446
+ const child = await loadTrashableChild(pre.parent, childId);
447
+ if (!child) {
448
+ res.status(404);
449
+ return res.send('Not found');
450
+ }
451
+ if (!await safeManagerPolicy(M, 'canForceDelete', RelatedForSoft, pre.user, pre.parent, child))
452
+ return forbidden(res, json);
453
+ const listUrl = parentBase.replace(':id', pre.recordId);
454
+ try {
455
+ await RM.forceDelete(childId);
456
+ }
457
+ catch (err) {
458
+ const message = err instanceof Error ? err.message : 'Force-delete failed';
459
+ res.status(500);
460
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
461
+ }
462
+ return sendMutationSuccess(req, res, json, {
463
+ id: childId, kind: 'rforce', title: `${M.getLabelSingular()} permanently deleted`, redirect: listUrl,
464
+ });
465
+ });
466
+ }
467
+ // ── M2M follow-up — manager-scoped action dispatch + detach ─────
468
+ // Two new routes per relation manager. Mounted unconditionally
469
+ // (even on hasMany managers) because handler-style actions are
470
+ // useful beyond M2M — any user-defined `Action.handler(...)` on a
471
+ // manager table needs a place to dispatch. The detach route is
472
+ // M2M-specific but cheap enough to register either way; non-M2M
473
+ // managers' `Action.relationDetach` factories return `visible=false`
474
+ // anyway, so the URL is unreachable in practice.
475
+ // Action dispatch — POST ${parentBase}/_action/:actionName
476
+ // Resolves the manager's table elements, finds the named action,
477
+ // and dispatches it with `ctx.relation = { parent, parentId, rel }`
478
+ // so M2M handlers can call `parent.related(rel).attach / detach`.
479
+ // Records hydrate against the related model (the rows visible in
480
+ // the manager's table are related-model records).
481
+ router.post(`${parentBase}/_action/:actionName`, async (req, res) => {
482
+ const json = wantsJson(req);
483
+ const pre = await requireParent(req, res, json);
484
+ if (!pre)
485
+ return;
486
+ const actionName = req.params['actionName'];
487
+ const body = await readFormBody(req);
488
+ const input = parseActionBody(body);
489
+ // Rebuild the manager's table so the dispatcher can find the
490
+ // action by name. Pure recreation — same context the page-data
491
+ // builder uses — so factories that close over `ctx` (URL,
492
+ // mode, parent record) see the same shape as at page render.
493
+ const managerCtx = {
494
+ basePath: base,
495
+ parentSlug: slug,
496
+ parentId: pre.recordId,
497
+ relationship: rel,
498
+ parentRecord: pre.parent,
499
+ related: Related,
500
+ mode,
501
+ };
502
+ const table = M.table(Table.make(), managerCtx);
503
+ const elements = [table];
504
+ // Stamp dispatch URLs so any nested action factories that read
505
+ // `dispatchUrl` (rare — most read it from the meta at render
506
+ // time) still see something sensible.
507
+ const listUrl = parentBase.replace(':id', pre.recordId);
508
+ tagActionDispatch(elements, listUrl);
509
+ const target = resolveDispatchTarget(elements, actionName);
510
+ if (!target) {
511
+ if (json) {
512
+ res.status(404);
513
+ return res.json({ ok: false, error: `Action "${actionName}" not found` });
514
+ }
515
+ res.status(404);
516
+ return res.send(`Action "${actionName}" not found on ${M.name}`);
517
+ }
518
+ const resolveRecord = Related?.model
519
+ ? (id) => Related.model.find(id)
520
+ : undefined;
521
+ const result = await dispatchAction(target.action, {
522
+ ...input,
523
+ request: req,
524
+ user: pre.user,
525
+ relation: { parent: pre.parent, parentId: pre.recordId, relationship: rel },
526
+ ...(target.rowField ? { rowField: target.rowField } : {}),
527
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
528
+ }, resolveRecord);
529
+ return sendActionResult(req, res, json, result, base, listUrl);
530
+ });
531
+ // Detach — POST ${parentBase}/:childId/_detach
532
+ // Direct row-action target for `Action.relationDetach`. Removes the
533
+ // pivot row only; the related record stays in place. IDOR check:
534
+ // verify the child is currently attached before calling detach so
535
+ // a tampered URL can't probe random ids.
536
+ router.post(`${parentBase}/:childId/_detach`, async (req, res) => {
537
+ const json = wantsJson(req);
538
+ const pre = await requireParent(req, res, json);
539
+ if (!pre)
540
+ return;
541
+ const childId = req.params['childId'];
542
+ if (mode !== 'belongsToMany' && mode !== 'morphToMany' && mode !== 'morphedByMany') {
543
+ // Detach is meaningless for hasMany — the user wants `delete`.
544
+ // Surface a clear 404 instead of silently no-op'ing.
545
+ res.status(404);
546
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)';
547
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
548
+ }
549
+ // Manager-only canDetach: pivot ops don't fall through to the
550
+ // related Resource. We don't have the related child loaded yet —
551
+ // pass `undefined` for the per-record arg; canDetach gates on
552
+ // (user, parent) by default and only sees `record` when a
553
+ // manager has explicitly overridden with a per-row predicate.
554
+ // Authors who need per-row gating can detect undefined and either
555
+ // load the child themselves or short-circuit.
556
+ // Two distinct accessors are needed under the real
557
+ // `@rudderjs/orm`:
558
+ // - `parent.related(rel)` returns a deferred QueryBuilder
559
+ // with `where / paginate` (IDOR read-side check).
560
+ // - `parent[rel]()` returns the pivot-mutation accessor with
561
+ // `attach / detach / sync` (write-side).
562
+ // Test stubs may collapse both onto the same `parent.related(rel)`
563
+ // shape — handle that fallback so existing tests keep passing.
564
+ let child = undefined;
565
+ const readSide = pre.parent
566
+ ?.related?.(rel);
567
+ if (!readSide) {
568
+ res.status(500);
569
+ const msg = `Parent.related("${rel}") missing — wrong relation type or ORM version?`;
570
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
571
+ }
572
+ try {
573
+ // IDOR: confirm the child is currently attached.
574
+ if (typeof readSide.paginate === 'function') {
575
+ const pk = Related?.model ? getPrimaryKey(Related.model) : 'id';
576
+ const out = await readSide.where(pk, '=', childId).paginate(1, 1);
577
+ child = Array.isArray(out.data) ? out.data[0] : undefined;
578
+ }
579
+ }
580
+ catch {
581
+ // fall through; null child means we couldn't verify — safer to 404
582
+ }
583
+ if (child === undefined) {
584
+ res.status(404);
585
+ return res.send('Not found');
586
+ }
587
+ if (!await safeManagerPolicy(M, 'canDetach', undefined, pre.user, pre.parent, child))
588
+ return forbidden(res, json);
589
+ // Real ORM: `parent[rel]()` returns the pivot accessor. Test
590
+ // stubs: `parent.related(rel)` may carry `detach` directly.
591
+ // Try the prototype-installed instance method first, then fall
592
+ // back to the read-side shape.
593
+ let writeAccessor;
594
+ const inst = pre.parent[rel];
595
+ if (typeof inst === 'function') {
596
+ try {
597
+ const out = inst.call(pre.parent);
598
+ if (out && typeof out.detach === 'function')
599
+ writeAccessor = out;
600
+ }
601
+ catch { /* fall through to legacy shape */ }
602
+ }
603
+ if (!writeAccessor && typeof readSide.detach === 'function') {
604
+ writeAccessor = readSide;
605
+ }
606
+ if (!writeAccessor) {
607
+ res.status(500);
608
+ const msg = `Pivot accessor missing on ${rel} — wrong relation type or ORM version?`;
609
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
610
+ }
611
+ try {
612
+ await writeAccessor.detach([childId]);
613
+ }
614
+ catch (err) {
615
+ const message = err instanceof Error ? err.message : 'Detach failed';
616
+ res.status(500);
617
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
618
+ }
619
+ const listUrl = parentBase.replace(':id', pre.recordId);
620
+ return sendMutationSuccess(req, res, json, {
621
+ id: childId, kind: 'rdetach', title: `${M.getLabelSingular()} detached`, redirect: listUrl,
622
+ });
623
+ });
624
+ // ── Phase B nested relation routes ──────────────────
625
+ // For each manager N declared under M.relations(), mount the
626
+ // depth-2 list/create/view/edit/delete handlers. Auth + chain
627
+ // IDOR are centralized in `nestedRelationManagerData` — route
628
+ // bodies dispatch the data builder and unwrap the tagged
629
+ // {ok:false,status:403} / null shapes. Surface area mirrors
630
+ // Phase A: no M2M attach/detach, no soft-delete restore on
631
+ // nested managers in v1 (open follow-ups if a consumer asks).
632
+ for (const N of M.relations()) {
633
+ const nestedRel = N.getRelationship();
634
+ const nestedBase = `${parentBase}/:childId/${nestedRel}`;
635
+ // Hoist the depth-2 related-resource lookups out of per-handler
636
+ // closures — same rationale as the depth-1 `Related` hoist above.
637
+ // `Related1` is the (M-side) related Resource (already hoisted as
638
+ // `Related`); `Related2` is the (N-side) related Resource.
639
+ const Related1 = Related;
640
+ const Related2 = Related1 ? findRelatedResource(N, Related1, cfg) : undefined;
641
+ // Build a `chain` tuple from the URL params for relayed calls
642
+ // into `relationManagerData`. The childId of the *outer* manager
643
+ // is the recordId of the leaf step.
644
+ const buildChain = (id, childId1) => [
645
+ { recordId: id, relationship: rel },
646
+ { recordId: childId1, relationship: nestedRel },
647
+ ];
648
+ // ── List ──
649
+ router.get(nestedBase, async (req, res) => {
650
+ const json = wantsJson(req);
651
+ const id = req.params['id'];
652
+ const childId1 = req.params['childId'];
653
+ const data = await relationManagerData(pilotiq, {
654
+ kind: 'nested-relation-list', slug,
655
+ chain: buildChain(id, childId1),
656
+ query: req.query,
657
+ }, req);
658
+ if (data === null) {
659
+ res.status(404);
660
+ return res.send('Not found');
661
+ }
662
+ if ('ok' in data && data.ok === false)
663
+ return forbidden(res, json);
664
+ return view('pilotiq.nested-relation-list', data);
665
+ });
666
+ // ── Create (GET) ──
667
+ router.get(`${nestedBase}/create`, async (req, res) => {
668
+ const json = wantsJson(req);
669
+ const id = req.params['id'];
670
+ const childId1 = req.params['childId'];
671
+ const data = await relationManagerData(pilotiq, {
672
+ kind: 'nested-relation-create', slug,
673
+ chain: buildChain(id, childId1),
674
+ }, req);
675
+ if (data === null) {
676
+ res.status(404);
677
+ return res.send('Not found');
678
+ }
679
+ if ('ok' in data && data.ok === false)
680
+ return forbidden(res, json);
681
+ return view('pilotiq.nested-relation-create', data);
682
+ });
683
+ // ── Create (POST) ──
684
+ router.post(`${nestedBase}/create`, async (req, res) => {
685
+ const json = wantsJson(req);
686
+ const id = req.params['id'];
687
+ const childId1 = req.params['childId'];
688
+ // Run the chain walk once to verify auth + IDOR + load child1.
689
+ // Any failure returns the same tagged shape we serve on GET.
690
+ const pre = await relationManagerData(pilotiq, {
691
+ kind: 'nested-relation-create', slug,
692
+ chain: buildChain(id, childId1),
693
+ }, req);
694
+ if (pre === null) {
695
+ res.status(404);
696
+ return res.send('Not found');
697
+ }
698
+ if ('ok' in pre && pre.ok === false)
699
+ return forbidden(res, json);
700
+ // Re-resolve the leaf manager's bits for form submit. We need
701
+ // the leaf parent record (`child1`) and the related class for
702
+ // save/loadRecord wiring. Reuse `findRelatedResource` against
703
+ // the chain walk's intermediate Resource (Related1).
704
+ if (!Related1) {
705
+ res.status(500);
706
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for create`;
707
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
708
+ }
709
+ if (!Related2?.model) {
710
+ res.status(500);
711
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for create`;
712
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
713
+ }
714
+ const user = await pilotiq.resolveUser(req);
715
+ const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined);
716
+ if (!child1) {
717
+ res.status(404);
718
+ return res.send('Not found');
719
+ }
720
+ const body = await readFormBody(req);
721
+ const { values } = splitMeta(body);
722
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1);
723
+ const nestedMode = Related1.model
724
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
725
+ : 'hasMany';
726
+ const form = N.form(Form.make(), {
727
+ basePath: base,
728
+ parentSlug: slug,
729
+ parentId: childId1,
730
+ relationship: nestedRel,
731
+ parentRecord: child1,
732
+ related: Related2,
733
+ mode: nestedMode,
734
+ chain: [{ slug, recordId: id, relationship: rel }],
735
+ });
736
+ if (Related2.model) {
737
+ if (!form.getSave())
738
+ form.save(modelSave(Related2.model));
739
+ if (!form.getLoadRecord())
740
+ form.loadRecord(modelLoadRecord(Related2));
741
+ }
742
+ // Polymorphic morph-column auto-injection mirrors the depth-1
743
+ // create handler — uses Related1 (the leaf parent's owner) as
744
+ // the morph source on the leaf relation.
745
+ if (nestedMode === 'morphMany' && Related1.model) {
746
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel);
747
+ if (!morphDesc) {
748
+ res.status(500);
749
+ const msg = `Nested manager ${N.name}: relations[${JSON.stringify(nestedRel)}] reports a polymorphic type but is missing morphName.`;
750
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
751
+ }
752
+ const morphPayload = computeMorphPayload(child1, morphDesc);
753
+ const existing = form.getMutateDataBeforeCreate();
754
+ form.mutateDataBeforeCreate(async (data, ctx) => {
755
+ const next = existing ? await existing(data, ctx) : data;
756
+ return { ...next, ...morphPayload };
757
+ });
758
+ }
759
+ const formCtx = {
760
+ values,
761
+ basePath: base,
762
+ parent: child1,
763
+ parentId: childId1,
764
+ relationship: nestedRel,
765
+ };
766
+ const result = await dispatchFormSubmit(form, values, formCtx);
767
+ if (!result.ok) {
768
+ if (json) {
769
+ res.status(422);
770
+ return res.json({ ok: false, errors: result.errors });
771
+ }
772
+ const data = await relationManagerData(pilotiq, {
773
+ kind: 'nested-relation-create', slug,
774
+ chain: buildChain(id, childId1),
775
+ prefill: { values, errors: result.errors ?? {} },
776
+ }, req);
777
+ res.status(422);
778
+ return view('pilotiq.nested-relation-create', data ?? {});
779
+ }
780
+ const redirect = normalizeRedirect(result.redirect, base) ?? listUrl;
781
+ return sendRedirectResponse(req, res, json, redirect, result.notifications);
782
+ });
783
+ // ── View ──
784
+ router.get(`${nestedBase}/:childId2`, async (req, res) => {
785
+ const json = wantsJson(req);
786
+ const id = req.params['id'];
787
+ const childId1 = req.params['childId'];
788
+ const childId2 = req.params['childId2'];
789
+ if (childId2 === 'create') {
790
+ res.status(404);
791
+ return res.send('Not found');
792
+ }
793
+ const data = await relationManagerData(pilotiq, {
794
+ kind: 'nested-relation-view', slug,
795
+ chain: buildChain(id, childId1),
796
+ childId: childId2,
797
+ }, req);
798
+ if (data === null) {
799
+ res.status(404);
800
+ return res.send('Not found');
801
+ }
802
+ if ('ok' in data && data.ok === false)
803
+ return forbidden(res, json);
804
+ return view('pilotiq.nested-relation-view', data);
805
+ });
806
+ // ── Edit (GET) ──
807
+ router.get(`${nestedBase}/:childId2/edit`, async (req, res) => {
808
+ const json = wantsJson(req);
809
+ const id = req.params['id'];
810
+ const childId1 = req.params['childId'];
811
+ const childId2 = req.params['childId2'];
812
+ const data = await relationManagerData(pilotiq, {
813
+ kind: 'nested-relation-edit', slug,
814
+ chain: buildChain(id, childId1),
815
+ childId: childId2,
816
+ }, req);
817
+ if (data === null) {
818
+ res.status(404);
819
+ return res.send('Not found');
820
+ }
821
+ if ('ok' in data && data.ok === false)
822
+ return forbidden(res, json);
823
+ return view('pilotiq.nested-relation-edit', data);
824
+ });
825
+ // ── Edit (POST) ──
826
+ router.post(`${nestedBase}/:childId2/edit`, async (req, res) => {
827
+ const json = wantsJson(req);
828
+ const id = req.params['id'];
829
+ const childId1 = req.params['childId'];
830
+ const childId2 = req.params['childId2'];
831
+ // Replay the chain to verify auth, IDOR, load child1+child2.
832
+ const pre = await relationManagerData(pilotiq, {
833
+ kind: 'nested-relation-edit', slug,
834
+ chain: buildChain(id, childId1),
835
+ childId: childId2,
836
+ }, req);
837
+ if (pre === null) {
838
+ res.status(404);
839
+ return res.send('Not found');
840
+ }
841
+ if ('ok' in pre && pre.ok === false)
842
+ return forbidden(res, json);
843
+ if (!Related1) {
844
+ res.status(500);
845
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for edit`;
846
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
847
+ }
848
+ if (!Related2?.model) {
849
+ res.status(500);
850
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for edit`;
851
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
852
+ }
853
+ const user = await pilotiq.resolveUser(req);
854
+ // Parallelize child1 + child2 loads — both depend only on `user`.
855
+ const [child1, child2] = await Promise.all([
856
+ findRecord(Related1, childId1, { user }).catch(() => undefined),
857
+ findRecord(Related2, childId2, { user }).catch(() => undefined),
858
+ ]);
859
+ if (!child1) {
860
+ res.status(404);
861
+ return res.send('Not found');
862
+ }
863
+ if (!child2) {
864
+ res.status(404);
865
+ return res.send('Not found');
866
+ }
867
+ const body = await readFormBody(req);
868
+ const { values } = splitMeta(body);
869
+ const editUrl = `${nestedBase}/${childId2}/edit`.replace(':id', id).replace(':childId', childId1);
870
+ const nestedMode = Related1.model
871
+ ? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
872
+ : 'hasMany';
873
+ const form = N.form(Form.make(), {
874
+ basePath: base,
875
+ parentSlug: slug,
876
+ parentId: childId1,
877
+ relationship: nestedRel,
878
+ parentRecord: child1,
879
+ related: Related2,
880
+ mode: nestedMode,
881
+ chain: [{ slug, recordId: id, relationship: rel }],
882
+ });
883
+ if (!form.getSave())
884
+ form.save(modelSave(Related2.model));
885
+ if (!form.getLoadRecord())
886
+ form.loadRecord(modelLoadRecord(Related2));
887
+ if (nestedMode === 'morphMany' && Related1.model) {
888
+ const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel);
889
+ if (morphDesc) {
890
+ const morphPayload = computeMorphPayload(child1, morphDesc);
891
+ const existing = form.getMutateDataBeforeUpdate();
892
+ form.mutateDataBeforeUpdate(async (data, ctx) => {
893
+ const next = existing ? await existing(data, ctx) : data;
894
+ return { ...next, ...morphPayload };
895
+ });
896
+ }
897
+ }
898
+ const formCtx = {
899
+ values,
900
+ basePath: base,
901
+ record: child2,
902
+ parent: child1,
903
+ parentId: childId1,
904
+ relationship: nestedRel,
905
+ };
906
+ const result = await dispatchFormSubmit(form, values, formCtx);
907
+ if (!result.ok) {
908
+ if (json) {
909
+ res.status(422);
910
+ return res.json({ ok: false, errors: result.errors });
911
+ }
912
+ const data = await relationManagerData(pilotiq, {
913
+ kind: 'nested-relation-edit', slug,
914
+ chain: buildChain(id, childId1),
915
+ childId: childId2,
916
+ prefill: { values, errors: result.errors ?? {} },
917
+ }, req);
918
+ res.status(422);
919
+ return view('pilotiq.nested-relation-edit', data ?? {});
920
+ }
921
+ const redirect = normalizeRedirect(result.redirect, base) ?? editUrl;
922
+ return sendRedirectResponse(req, res, json, redirect, result.notifications);
923
+ });
924
+ // ── Delete ──
925
+ router.post(`${nestedBase}/:childId2/delete`, async (req, res) => {
926
+ const json = wantsJson(req);
927
+ const id = req.params['id'];
928
+ const childId1 = req.params['childId'];
929
+ const childId2 = req.params['childId2'];
930
+ // Replay the chain to verify auth + IDOR + load child2.
931
+ // We piggy-back on the edit scope's checks (canEdit on the
932
+ // leaf manager — same gate the depth-1 delete uses today via
933
+ // the relation-edit scope).
934
+ const pre = await relationManagerData(pilotiq, {
935
+ kind: 'nested-relation-edit', slug,
936
+ chain: buildChain(id, childId1),
937
+ childId: childId2,
938
+ }, req);
939
+ if (pre === null) {
940
+ res.status(404);
941
+ return res.send('Not found');
942
+ }
943
+ if ('ok' in pre && pre.ok === false)
944
+ return forbidden(res, json);
945
+ if (!Related1) {
946
+ res.status(500);
947
+ const msg = `Nested manager ${N.name}: cannot resolve middle Resource for delete`;
948
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
949
+ }
950
+ if (!Related2?.model) {
951
+ res.status(500);
952
+ const msg = `Nested manager ${N.name}: cannot resolve related Resource for delete`;
953
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
954
+ }
955
+ const user = await pilotiq.resolveUser(req);
956
+ // Parallelize child1 + child2 loads — both depend only on `user`.
957
+ const [child1, child2] = await Promise.all([
958
+ findRecord(Related1, childId1, { user }).catch(() => undefined),
959
+ findRecord(Related2, childId2, { user }).catch(() => undefined),
960
+ ]);
961
+ if (!child1) {
962
+ res.status(404);
963
+ return res.send('Not found');
964
+ }
965
+ if (!child2) {
966
+ res.status(404);
967
+ return res.send('Not found');
968
+ }
969
+ if (!await safeManagerPolicy(N, 'canDelete', Related2, user, child1, child2))
970
+ return forbidden(res, json);
971
+ const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1);
972
+ try {
973
+ await Related2.model.delete(childId2);
974
+ }
975
+ catch (err) {
976
+ const message = err instanceof Error ? err.message : 'Delete failed';
977
+ res.status(500);
978
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
979
+ }
980
+ return sendMutationSuccess(req, res, json, {
981
+ id: childId2, kind: 'nrdelete', title: `${N.getLabelSingular()} deleted`, redirect: listUrl,
982
+ });
983
+ });
984
+ // ── Phase B follow-up — nested action / detach / soft-delete ──
985
+ // Mirror the depth-1 manager surface (`_action`, `_detach`,
986
+ // `restore`, `force-delete`) under the nested manager. Auth +
987
+ // chain IDOR centralized in `resolveRelationChain`; each route
988
+ // layers its own scope-specific gate (canDetach / canRestore /
989
+ // canForceDelete; the action route mirrors depth-1 by not adding
990
+ // an extra manager-level gate beyond the chain walk).
991
+ const nestedChainSlug = slug;
992
+ const requireNestedChain = async (req, res, json) => {
993
+ const id = req.params['id'];
994
+ const child1Id = req.params['childId'];
995
+ const user = await pilotiq.resolveUser(req);
996
+ const resolved = await resolveRelationChain(pilotiq, {
997
+ kind: 'nested-relation-list',
998
+ slug: nestedChainSlug,
999
+ chain: [
1000
+ { recordId: id, relationship: rel },
1001
+ { recordId: child1Id, relationship: nestedRel },
1002
+ ],
1003
+ }, user);
1004
+ if (resolved === null) {
1005
+ res.status(404);
1006
+ res.send('Not found');
1007
+ return undefined;
1008
+ }
1009
+ if ('ok' in resolved) {
1010
+ forbidden(res, json);
1011
+ return undefined;
1012
+ }
1013
+ return { user, resolved, parentId: id, child1Id };
1014
+ };
1015
+ // Listing URL (filled per request — `:id` / `:childId` get baked
1016
+ // in once the params are known). All four routes redirect here
1017
+ // on success so users land back on the nested-relation list.
1018
+ const nestedListUrlFor = (id, child1Id) => nestedBase.replace(':id', id).replace(':childId', child1Id);
1019
+ // ── Action dispatch — POST ${nestedBase}/_action/:actionName ──
1020
+ // Resolves N's table elements, finds the named action, dispatches
1021
+ // it with `ctx.relation = { parent: child1, parentId, rel }` so
1022
+ // M2M handlers on the nested manager can call accessor methods.
1023
+ // Handler-style actions are useful on hasMany too — mounted
1024
+ // unconditionally.
1025
+ router.post(`${nestedBase}/_action/:actionName`, async (req, res) => {
1026
+ const json = wantsJson(req);
1027
+ const pre = await requireNestedChain(req, res, json);
1028
+ if (!pre)
1029
+ return;
1030
+ const { resolved } = pre;
1031
+ const { Related1, child1, M2, Related2, child2Mode } = resolved;
1032
+ const actionName = req.params['actionName'];
1033
+ const body = await readFormBody(req);
1034
+ const input = parseActionBody(body);
1035
+ // Manager ctx for N — same shape `nestedManagerCtx` builds for
1036
+ // the data-builder side, so factories that close over `ctx`
1037
+ // (URL templates, mode-aware visibility) see the same view as
1038
+ // at page render.
1039
+ const nestedManagerCtxObj = {
1040
+ basePath: base,
1041
+ parentSlug: resolved.R.getSlug(),
1042
+ parentId: pre.child1Id, // immediate parent of N = child1
1043
+ relationship: nestedRel,
1044
+ parentRecord: child1,
1045
+ related: Related2,
1046
+ mode: child2Mode,
1047
+ chain: [{
1048
+ slug: resolved.R.getSlug(),
1049
+ recordId: pre.parentId,
1050
+ relationship: rel,
1051
+ }],
1052
+ };
1053
+ const table = M2.table(Table.make(), nestedManagerCtxObj);
1054
+ const elements = [table];
1055
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id);
1056
+ tagActionDispatch(elements, listUrl);
1057
+ const target = resolveDispatchTarget(elements, actionName);
1058
+ if (!target) {
1059
+ if (json) {
1060
+ res.status(404);
1061
+ return res.json({ ok: false, error: `Action "${actionName}" not found` });
1062
+ }
1063
+ res.status(404);
1064
+ return res.send(`Action "${actionName}" not found on ${M2.name}`);
1065
+ }
1066
+ const resolveRecord = Related2?.model
1067
+ ? (id) => Related2.model.find(id)
1068
+ : undefined;
1069
+ const result = await dispatchAction(target.action, {
1070
+ ...input,
1071
+ request: req,
1072
+ user: pre.user,
1073
+ relation: { parent: child1, parentId: pre.child1Id, relationship: nestedRel },
1074
+ ...(target.rowField ? { rowField: target.rowField } : {}),
1075
+ ...(target.formSchema ? { formSchema: target.formSchema } : {}),
1076
+ }, resolveRecord);
1077
+ return sendActionResult(req, res, json, result, base, listUrl);
1078
+ });
1079
+ // ── Detach — POST ${nestedBase}/:childId2/_detach ──
1080
+ // M2M-only direct row-detach. IDOR-checks the grandchild against
1081
+ // child1.related(nestedRel), then calls accessor.detach. Mirrors
1082
+ // the depth-1 detach route at line 1955.
1083
+ router.post(`${nestedBase}/:childId2/_detach`, async (req, res) => {
1084
+ const json = wantsJson(req);
1085
+ const pre = await requireNestedChain(req, res, json);
1086
+ if (!pre)
1087
+ return;
1088
+ const childId2 = req.params['childId2'];
1089
+ const { resolved } = pre;
1090
+ const { Related1, child1, M2, Related2, child2Mode } = resolved;
1091
+ if (child2Mode !== 'belongsToMany' && child2Mode !== 'morphToMany' && child2Mode !== 'morphedByMany') {
1092
+ res.status(404);
1093
+ const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)';
1094
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
1095
+ }
1096
+ // IDOR: confirm child2 is currently attached to child1 under
1097
+ // nestedRel. Read-side accessor (`child1.related(nestedRel)`)
1098
+ // returns a deferred QueryBuilder; we never bypass it.
1099
+ const readSide = child1
1100
+ ?.related?.(nestedRel);
1101
+ if (!readSide) {
1102
+ res.status(500);
1103
+ const msg = `child1.related("${nestedRel}") missing — wrong relation type or ORM version?`;
1104
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
1105
+ }
1106
+ let child2 = undefined;
1107
+ try {
1108
+ if (typeof readSide.paginate === 'function') {
1109
+ const pk = Related2?.model ? getPrimaryKey(Related2.model) : 'id';
1110
+ const out = await readSide.where(pk, '=', childId2).paginate(1, 1);
1111
+ child2 = Array.isArray(out.data) ? out.data[0] : undefined;
1112
+ }
1113
+ }
1114
+ catch { /* fall through */ }
1115
+ if (child2 === undefined) {
1116
+ res.status(404);
1117
+ return res.send('Not found');
1118
+ }
1119
+ if (!await safeManagerPolicy(M2, 'canDetach', Related2, pre.user, child1, child2))
1120
+ return forbidden(res, json);
1121
+ // Real ORM: child1[nestedRel]() returns the pivot accessor
1122
+ // with attach/detach/sync. Test stubs may collapse onto
1123
+ // `child1.related(nestedRel)` — try both.
1124
+ let writeAccessor;
1125
+ const inst = child1[nestedRel];
1126
+ if (typeof inst === 'function') {
1127
+ try {
1128
+ const out = inst.call(child1);
1129
+ if (out && typeof out.detach === 'function')
1130
+ writeAccessor = out;
1131
+ }
1132
+ catch { /* fall through */ }
1133
+ }
1134
+ if (!writeAccessor && typeof readSide.detach === 'function') {
1135
+ writeAccessor = readSide;
1136
+ }
1137
+ if (!writeAccessor) {
1138
+ res.status(500);
1139
+ const msg = `Pivot accessor missing on ${nestedRel} — wrong relation type or ORM version?`;
1140
+ return json ? res.json({ ok: false, error: msg }) : res.send(msg);
1141
+ }
1142
+ try {
1143
+ await writeAccessor.detach([childId2]);
1144
+ }
1145
+ catch (err) {
1146
+ const message = err instanceof Error ? err.message : 'Detach failed';
1147
+ res.status(500);
1148
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
1149
+ }
1150
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id);
1151
+ return sendMutationSuccess(req, res, json, {
1152
+ id: childId2, kind: 'nrdetach', title: `${M2.getLabelSingular()} detached`, redirect: listUrl,
1153
+ });
1154
+ });
1155
+ // ── Soft-delete: restore + force-delete ───────────────────────
1156
+ // Opt in only when Related2 has `softDeletes = true` AND its
1157
+ // model carries `restore` / `forceDelete`. Mirrors the depth-1
1158
+ // routes at line 1804+. IDOR runs against child1.related(nestedRel)
1159
+ // broadened with `withTrashed()` so trashed grandchildren resolve.
1160
+ const Related1ForSoft = Related1;
1161
+ const Related2ForSoft = Related2;
1162
+ if (Related2ForSoft?.softDeletes) {
1163
+ const RM2 = Related2ForSoft.model;
1164
+ if (!RM2) {
1165
+ throw new Error(`[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but no model. ` +
1166
+ `Wire one up or unset softDeletes.`);
1167
+ }
1168
+ if (typeof RM2.restore !== 'function' || typeof RM2.forceDelete !== 'function') {
1169
+ 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. ` +
1170
+ `Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`);
1171
+ }
1172
+ // Like the depth-1 helper: load the grandchild via the parent's
1173
+ // relation query, broadened with `withTrashed()`. Returns
1174
+ // undefined when the lookup misses or the grandchild doesn't
1175
+ // belong to child1 under nestedRel.
1176
+ const loadTrashableGrandchild = async (parentChild, child2Id) => {
1177
+ const pk = (RM2.primaryKey ?? 'id');
1178
+ const q = parentChild.related(nestedRel);
1179
+ return findInQueryWithTrashed(q, pk, child2Id);
1180
+ };
1181
+ // Restore — POST ${nestedBase}/:childId2/restore
1182
+ router.post(`${nestedBase}/:childId2/restore`, async (req, res) => {
1183
+ const json = wantsJson(req);
1184
+ const pre = await requireNestedChain(req, res, json);
1185
+ if (!pre)
1186
+ return;
1187
+ const childId2 = req.params['childId2'];
1188
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2);
1189
+ if (!child2) {
1190
+ res.status(404);
1191
+ return res.send('Not found');
1192
+ }
1193
+ if (!await safeManagerPolicy(N, 'canRestore', Related2ForSoft, pre.user, pre.resolved.child1, child2))
1194
+ return forbidden(res, json);
1195
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id);
1196
+ try {
1197
+ await RM2.restore(childId2);
1198
+ }
1199
+ catch (err) {
1200
+ const message = err instanceof Error ? err.message : 'Restore failed';
1201
+ res.status(500);
1202
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
1203
+ }
1204
+ return sendMutationSuccess(req, res, json, {
1205
+ id: childId2, kind: 'nrrestore', title: `${N.getLabelSingular()} restored`, redirect: listUrl,
1206
+ });
1207
+ });
1208
+ // Force-delete — POST ${nestedBase}/:childId2/force-delete
1209
+ router.post(`${nestedBase}/:childId2/force-delete`, async (req, res) => {
1210
+ const json = wantsJson(req);
1211
+ const pre = await requireNestedChain(req, res, json);
1212
+ if (!pre)
1213
+ return;
1214
+ const childId2 = req.params['childId2'];
1215
+ const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2);
1216
+ if (!child2) {
1217
+ res.status(404);
1218
+ return res.send('Not found');
1219
+ }
1220
+ if (!await safeManagerPolicy(N, 'canForceDelete', Related2ForSoft, pre.user, pre.resolved.child1, child2))
1221
+ return forbidden(res, json);
1222
+ const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id);
1223
+ try {
1224
+ await RM2.forceDelete(childId2);
1225
+ }
1226
+ catch (err) {
1227
+ const message = err instanceof Error ? err.message : 'Force-delete failed';
1228
+ res.status(500);
1229
+ return json ? res.json({ ok: false, error: message }) : res.send(message);
1230
+ }
1231
+ return sendMutationSuccess(req, res, json, {
1232
+ id: childId2, kind: 'nrforce', title: `${N.getLabelSingular()} permanently deleted`, redirect: listUrl,
1233
+ });
1234
+ });
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ //# sourceMappingURL=relations.js.map