@pilotiq/pilotiq 0.23.1 → 0.24.2

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