@pilotiq/pilotiq 0.24.1 → 0.24.3

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