@pilotiq/pilotiq 0.24.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 (480) hide show
  1. package/CHANGELOG.md +33 -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/package.json +6 -1
  15. package/.turbo/turbo-build.log +0 -8
  16. package/CLAUDE.md +0 -265
  17. package/src/Cluster.test.ts +0 -283
  18. package/src/Cluster.ts +0 -83
  19. package/src/Column.test.ts +0 -199
  20. package/src/Column.ts +0 -710
  21. package/src/Global.test.ts +0 -367
  22. package/src/Global.ts +0 -169
  23. package/src/Page.test.ts +0 -114
  24. package/src/Page.ts +0 -208
  25. package/src/Pilotiq.perf.test.ts +0 -252
  26. package/src/Pilotiq.test.ts +0 -129
  27. package/src/Pilotiq.ts +0 -1158
  28. package/src/PilotiqRegistry.ts +0 -36
  29. package/src/PilotiqServiceProvider.ts +0 -121
  30. package/src/RelationManager.test.ts +0 -400
  31. package/src/RelationManager.ts +0 -527
  32. package/src/RenderHook.test.ts +0 -252
  33. package/src/RenderHook.ts +0 -242
  34. package/src/Resource.test.ts +0 -284
  35. package/src/Resource.ts +0 -526
  36. package/src/RightPanel.test.ts +0 -202
  37. package/src/RightPanel.ts +0 -132
  38. package/src/Tab.test.ts +0 -91
  39. package/src/Tab.ts +0 -156
  40. package/src/UserMenuItem.ts +0 -145
  41. package/src/actions/Action.test.ts +0 -2526
  42. package/src/actions/Action.ts +0 -1515
  43. package/src/actions/ActionGroup.test.ts +0 -112
  44. package/src/actions/ActionGroup.ts +0 -173
  45. package/src/actions/attachFactory.ts +0 -172
  46. package/src/actions/bulkFactories.ts +0 -168
  47. package/src/actions/crudFactories.ts +0 -220
  48. package/src/actions/exportFactory.ts +0 -225
  49. package/src/actions/factoryHelpers.ts +0 -177
  50. package/src/actions/importFactory.ts +0 -243
  51. package/src/actions/index.ts +0 -17
  52. package/src/actions/m2mFactories.ts +0 -193
  53. package/src/actions/relationFactories.ts +0 -372
  54. package/src/applyPageHooks.test.ts +0 -463
  55. package/src/applyPageHooks.ts +0 -330
  56. package/src/authorization.test.ts +0 -483
  57. package/src/breadcrumbs.test.ts +0 -238
  58. package/src/cells/coerce.test.ts +0 -85
  59. package/src/cells/coerce.ts +0 -84
  60. package/src/clusterPaths.ts +0 -35
  61. package/src/columns/BadgeColumn.test.ts +0 -54
  62. package/src/columns/BadgeColumn.ts +0 -32
  63. package/src/columns/BooleanColumn.test.ts +0 -41
  64. package/src/columns/BooleanColumn.ts +0 -18
  65. package/src/columns/ColorColumn.test.ts +0 -37
  66. package/src/columns/ColorColumn.ts +0 -38
  67. package/src/columns/IconColumn.test.ts +0 -54
  68. package/src/columns/IconColumn.ts +0 -37
  69. package/src/columns/ImageColumn.test.ts +0 -41
  70. package/src/columns/ImageColumn.ts +0 -28
  71. package/src/columns/SelectColumn.ts +0 -98
  72. package/src/columns/TextColumn.test.ts +0 -190
  73. package/src/columns/TextColumn.ts +0 -20
  74. package/src/columns/TextInputColumn.ts +0 -68
  75. package/src/columns/ToggleColumn.ts +0 -46
  76. package/src/columns/editableColumns.test.ts +0 -238
  77. package/src/columns/index.ts +0 -9
  78. package/src/defaultGlobalPages.ts +0 -95
  79. package/src/defaultPages.test.ts +0 -634
  80. package/src/defaultPages.ts +0 -617
  81. package/src/defaultViewPage.test.ts +0 -147
  82. package/src/elements/Form.test.ts +0 -223
  83. package/src/elements/Form.ts +0 -416
  84. package/src/elements/ListTabs.ts +0 -28
  85. package/src/elements/Table.test.ts +0 -422
  86. package/src/elements/Table.ts +0 -850
  87. package/src/elements/TableGroup.test.ts +0 -260
  88. package/src/elements/TableGroup.ts +0 -334
  89. package/src/elements/dispatchAction.test.ts +0 -463
  90. package/src/elements/dispatchAction.ts +0 -355
  91. package/src/elements/dispatchForm.test.ts +0 -477
  92. package/src/elements/dispatchForm.ts +0 -1993
  93. package/src/elements/dispatchTable.test.ts +0 -1514
  94. package/src/elements/dispatchTable.ts +0 -745
  95. package/src/elements/index.ts +0 -21
  96. package/src/entries/BadgeEntry.ts +0 -39
  97. package/src/entries/CodeEntry.test.ts +0 -40
  98. package/src/entries/CodeEntry.ts +0 -52
  99. package/src/entries/ColorEntry.ts +0 -63
  100. package/src/entries/ComponentEntry.test.ts +0 -173
  101. package/src/entries/ComponentEntry.ts +0 -95
  102. package/src/entries/Entry.ts +0 -304
  103. package/src/entries/IconEntry.ts +0 -49
  104. package/src/entries/ImageEntry.ts +0 -61
  105. package/src/entries/KeyValueEntry.ts +0 -47
  106. package/src/entries/RepeatableEntry.test.ts +0 -239
  107. package/src/entries/RepeatableEntry.ts +0 -173
  108. package/src/entries/TextEntry.test.ts +0 -394
  109. package/src/entries/TextEntry.ts +0 -60
  110. package/src/entries/index.ts +0 -12
  111. package/src/entries/leaves.test.ts +0 -306
  112. package/src/entries/registry.ts +0 -54
  113. package/src/fields/BuilderField.test.ts +0 -1188
  114. package/src/fields/BuilderField.ts +0 -605
  115. package/src/fields/BuilderRelationship.test.ts +0 -811
  116. package/src/fields/CheckboxField.test.ts +0 -44
  117. package/src/fields/CheckboxField.ts +0 -27
  118. package/src/fields/CheckboxListField.test.ts +0 -99
  119. package/src/fields/CheckboxListField.ts +0 -66
  120. package/src/fields/ColorPickerField.test.ts +0 -33
  121. package/src/fields/ColorPickerField.ts +0 -25
  122. package/src/fields/DateField.ts +0 -54
  123. package/src/fields/DateTimeField.test.ts +0 -55
  124. package/src/fields/EmailField.ts +0 -16
  125. package/src/fields/Field.test.ts +0 -654
  126. package/src/fields/Field.ts +0 -817
  127. package/src/fields/FileUploadField.test.ts +0 -143
  128. package/src/fields/FileUploadField.ts +0 -159
  129. package/src/fields/HiddenField.test.ts +0 -27
  130. package/src/fields/HiddenField.ts +0 -28
  131. package/src/fields/KeyValueField.test.ts +0 -105
  132. package/src/fields/KeyValueField.ts +0 -55
  133. package/src/fields/MarkdownField.test.ts +0 -167
  134. package/src/fields/MarkdownField.ts +0 -162
  135. package/src/fields/NumberField.ts +0 -33
  136. package/src/fields/RadioField.test.ts +0 -94
  137. package/src/fields/RadioField.ts +0 -67
  138. package/src/fields/RepeaterField.test.ts +0 -1806
  139. package/src/fields/RepeaterField.ts +0 -939
  140. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  141. package/src/fields/RepeaterSimple.test.ts +0 -248
  142. package/src/fields/RowButton.test.ts +0 -219
  143. package/src/fields/RowButton.ts +0 -135
  144. package/src/fields/SelectField.test.ts +0 -192
  145. package/src/fields/SelectField.ts +0 -235
  146. package/src/fields/SliderField.test.ts +0 -50
  147. package/src/fields/SliderField.ts +0 -53
  148. package/src/fields/SlugField.ts +0 -24
  149. package/src/fields/TagsInputField.test.ts +0 -154
  150. package/src/fields/TagsInputField.ts +0 -133
  151. package/src/fields/TextField.test.ts +0 -213
  152. package/src/fields/TextField.ts +0 -177
  153. package/src/fields/TextareaField.test.ts +0 -58
  154. package/src/fields/TextareaField.ts +0 -59
  155. package/src/fields/ToggleButtonsField.test.ts +0 -106
  156. package/src/fields/ToggleButtonsField.ts +0 -59
  157. package/src/fields/ToggleField.ts +0 -16
  158. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  159. package/src/fields/optionsResolver.ts +0 -95
  160. package/src/fields/resolveField.ts +0 -28
  161. package/src/filters/BooleanFilter.ts +0 -35
  162. package/src/filters/DateRangeFilter.test.ts +0 -194
  163. package/src/filters/DateRangeFilter.ts +0 -148
  164. package/src/filters/Filter.test.ts +0 -268
  165. package/src/filters/Filter.ts +0 -184
  166. package/src/filters/FormFilter.test.ts +0 -238
  167. package/src/filters/FormFilter.ts +0 -215
  168. package/src/filters/MultiSelectFilter.test.ts +0 -119
  169. package/src/filters/MultiSelectFilter.ts +0 -78
  170. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  171. package/src/filters/QueryBuilderFilter.ts +0 -398
  172. package/src/filters/SelectFilter.ts +0 -46
  173. package/src/filters/TernaryFilter.test.ts +0 -160
  174. package/src/filters/TernaryFilter.ts +0 -72
  175. package/src/filters/TrashedFilter.test.ts +0 -149
  176. package/src/filters/TrashedFilter.ts +0 -55
  177. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  178. package/src/filters/queryBuilder/Constraint.ts +0 -115
  179. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  180. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  181. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  182. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  183. package/src/filters/queryBuilder/index.ts +0 -12
  184. package/src/icons/index.ts +0 -2
  185. package/src/icons/lucide.ts +0 -204
  186. package/src/icons/registry.test.ts +0 -56
  187. package/src/icons/registry.ts +0 -41
  188. package/src/icons/types.ts +0 -47
  189. package/src/index.ts +0 -525
  190. package/src/io/csv.test.ts +0 -142
  191. package/src/io/csv.ts +0 -170
  192. package/src/nestedRelationManagerData.test.ts +0 -547
  193. package/src/notifications/Notification.test.ts +0 -210
  194. package/src/notifications/Notification.ts +0 -354
  195. package/src/notifications/broadcast.test.ts +0 -110
  196. package/src/notifications/broadcast.ts +0 -95
  197. package/src/notifications/database.test.ts +0 -383
  198. package/src/notifications/database.ts +0 -398
  199. package/src/notifications/databaseNotifications.test.ts +0 -187
  200. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  201. package/src/notifications/dispatchNotificationAction.ts +0 -142
  202. package/src/notifications/flash.test.ts +0 -89
  203. package/src/notifications/flash.ts +0 -71
  204. package/src/notifications/index.ts +0 -45
  205. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  206. package/src/notifications/registerBroadcastAuth.ts +0 -100
  207. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  208. package/src/notifications/resolveSavedNotification.ts +0 -59
  209. package/src/notifications/types.ts +0 -93
  210. package/src/orm/m2mAccessor.ts +0 -66
  211. package/src/orm/modelDefaults.test.ts +0 -633
  212. package/src/orm/modelDefaults.ts +0 -666
  213. package/src/pageData/breadcrumbs.ts +0 -288
  214. package/src/pageData/forms.ts +0 -578
  215. package/src/pageData/helpers.ts +0 -857
  216. package/src/pageData/misc.ts +0 -347
  217. package/src/pageData/navigation.ts +0 -842
  218. package/src/pageData/relationPages.ts +0 -1248
  219. package/src/pageData/relationTabs.ts +0 -286
  220. package/src/pageData/resourcePages.ts +0 -609
  221. package/src/pageData.test.ts +0 -1545
  222. package/src/pageData.ts +0 -341
  223. package/src/plugins/index.ts +0 -8
  224. package/src/plugins/themeEditor.test.ts +0 -36
  225. package/src/plugins/themeEditor.ts +0 -45
  226. package/src/react/AppShell.tsx +0 -251
  227. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  228. package/src/react/CollabRoomContext.ts +0 -98
  229. package/src/react/CollabTextRendererRegistry.ts +0 -102
  230. package/src/react/CommandPalette.tsx +0 -375
  231. package/src/react/CurrentUserContext.tsx +0 -50
  232. package/src/react/CustomPageWrapperGate.tsx +0 -69
  233. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  234. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  235. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  236. package/src/react/FieldPresenceRegistry.ts +0 -46
  237. package/src/react/FormCollabBindingRegistry.ts +0 -242
  238. package/src/react/FormStateContext.tsx +0 -591
  239. package/src/react/HeadHooks.tsx +0 -126
  240. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  241. package/src/react/MarkdownEditorRegistry.ts +0 -107
  242. package/src/react/NotificationActionStrip.tsx +0 -263
  243. package/src/react/NotificationBell.tsx +0 -426
  244. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  245. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  246. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  247. package/src/react/PendingSuggestionsContext.tsx +0 -172
  248. package/src/react/RecordWrapperGate.tsx +0 -58
  249. package/src/react/RecordWrapperRegistry.ts +0 -39
  250. package/src/react/RenderHookSlot.tsx +0 -32
  251. package/src/react/RightSidebar.tsx +0 -257
  252. package/src/react/RightSidebarContext.tsx +0 -234
  253. package/src/react/RightSidebarTrigger.tsx +0 -53
  254. package/src/react/RowCoordsContext.tsx +0 -23
  255. package/src/react/SchemaRenderer.tsx +0 -549
  256. package/src/react/SearchTrigger.tsx +0 -46
  257. package/src/react/ThemeProvider.tsx +0 -93
  258. package/src/react/ThemeSettingsPage.tsx +0 -579
  259. package/src/react/ThemeToggle.tsx +0 -20
  260. package/src/react/Toaster.tsx +0 -158
  261. package/src/react/UserMenu.tsx +0 -196
  262. package/src/react/WidgetDataContext.tsx +0 -157
  263. package/src/react/cells/EditableCell.tsx +0 -389
  264. package/src/react/component-slots.test.ts +0 -103
  265. package/src/react/component-slots.ts +0 -116
  266. package/src/react/fieldJsHandler.test.ts +0 -166
  267. package/src/react/fieldJsHandler.ts +0 -79
  268. package/src/react/fields/BuilderInput.tsx +0 -1078
  269. package/src/react/fields/CheckboxInput.tsx +0 -39
  270. package/src/react/fields/CheckboxListInput.tsx +0 -102
  271. package/src/react/fields/ColorInput.tsx +0 -71
  272. package/src/react/fields/DateFieldInput.tsx +0 -70
  273. package/src/react/fields/DateTimeInput.tsx +0 -62
  274. package/src/react/fields/FieldShell.tsx +0 -348
  275. package/src/react/fields/FileUploadInput.tsx +0 -639
  276. package/src/react/fields/HiddenInput.tsx +0 -17
  277. package/src/react/fields/KeyValueInput.tsx +0 -230
  278. package/src/react/fields/MarkdownInput.tsx +0 -560
  279. package/src/react/fields/RadioInput.tsx +0 -81
  280. package/src/react/fields/RepeaterInput.test.ts +0 -116
  281. package/src/react/fields/RepeaterInput.tsx +0 -1420
  282. package/src/react/fields/SelectFieldInput.tsx +0 -280
  283. package/src/react/fields/SliderInput.tsx +0 -81
  284. package/src/react/fields/TagsInput.tsx +0 -283
  285. package/src/react/fields/TextLikeInput.tsx +0 -256
  286. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  287. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  288. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  289. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  290. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  291. package/src/react/fields/repeaterReconcile.ts +0 -104
  292. package/src/react/fields/rowChromeButton.tsx +0 -336
  293. package/src/react/fields/rowState.ts +0 -106
  294. package/src/react/fields/syncRowGates.test.ts +0 -202
  295. package/src/react/fields/syncRowGates.ts +0 -66
  296. package/src/react/fields/textInputControls.tsx +0 -238
  297. package/src/react/fields/useRowReorderDnd.ts +0 -78
  298. package/src/react/formStateHelpers.test.ts +0 -508
  299. package/src/react/formStateHelpers.ts +0 -381
  300. package/src/react/hooks/use-mobile.ts +0 -19
  301. package/src/react/icon-context.tsx +0 -60
  302. package/src/react/index.ts +0 -194
  303. package/src/react/layouts/SidebarLayout.tsx +0 -250
  304. package/src/react/layouts/TopbarLayout.tsx +0 -258
  305. package/src/react/navigate.tsx +0 -37
  306. package/src/react/onProviderSynced.test.ts +0 -90
  307. package/src/react/parseRecordEditUrl.test.ts +0 -122
  308. package/src/react/parseRecordEditUrl.ts +0 -94
  309. package/src/react/persistedState.ts +0 -40
  310. package/src/react/registry.ts +0 -48
  311. package/src/react/right-panel-registry.tsx +0 -47
  312. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  313. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  314. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  315. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  316. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  317. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  318. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  319. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  320. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  321. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  322. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  323. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  324. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  325. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  326. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  327. package/src/react/schemaRenderer/constants.ts +0 -50
  328. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  329. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  330. package/src/react/schemaRenderer/helpers.tsx +0 -81
  331. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  332. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  333. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  334. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  335. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  336. package/src/react/schemaRenderer/table/links.tsx +0 -112
  337. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  338. package/src/react/schemaRenderer/table/url.tsx +0 -143
  339. package/src/react/theme-preview/apply.ts +0 -99
  340. package/src/react/theme-preview/build-html.ts +0 -436
  341. package/src/react/ui/button.tsx +0 -51
  342. package/src/react/ui/calendar.tsx +0 -67
  343. package/src/react/ui/checkbox.tsx +0 -29
  344. package/src/react/ui/dialog.tsx +0 -108
  345. package/src/react/ui/dropdown-menu.tsx +0 -97
  346. package/src/react/ui/input.tsx +0 -20
  347. package/src/react/ui/label.tsx +0 -21
  348. package/src/react/ui/popover.tsx +0 -50
  349. package/src/react/ui/select.tsx +0 -169
  350. package/src/react/ui/separator.tsx +0 -25
  351. package/src/react/ui/sheet.tsx +0 -136
  352. package/src/react/ui/sidebar.tsx +0 -723
  353. package/src/react/ui/skeleton.tsx +0 -13
  354. package/src/react/ui/slider.tsx +0 -34
  355. package/src/react/ui/switch.tsx +0 -28
  356. package/src/react/ui/table.tsx +0 -105
  357. package/src/react/ui/tabs.tsx +0 -63
  358. package/src/react/ui/textarea.tsx +0 -18
  359. package/src/react/ui/tooltip.tsx +0 -64
  360. package/src/react/useResizableWidth.ts +0 -139
  361. package/src/react/utils.ts +0 -6
  362. package/src/react/widgetRegistry.test.ts +0 -43
  363. package/src/react/widgetRegistry.ts +0 -50
  364. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  365. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  366. package/src/react/widgets/ViewRenderer.tsx +0 -71
  367. package/src/relationManagerData.test.ts +0 -1595
  368. package/src/richtext/index.ts +0 -8
  369. package/src/richtext/registry.ts +0 -89
  370. package/src/routes/globals.ts +0 -148
  371. package/src/routes/guard.test.ts +0 -325
  372. package/src/routes/helpers.ts +0 -704
  373. package/src/routes/pages.ts +0 -175
  374. package/src/routes/panel.ts +0 -204
  375. package/src/routes/relations.ts +0 -1243
  376. package/src/routes/resources.ts +0 -781
  377. package/src/routes/theme.ts +0 -91
  378. package/src/routes-nested-relations.test.ts +0 -676
  379. package/src/routes-relations.test.ts +0 -972
  380. package/src/routes.test.ts +0 -2027
  381. package/src/routes.ts +0 -303
  382. package/src/schema/Alert.test.ts +0 -109
  383. package/src/schema/Alert.ts +0 -131
  384. package/src/schema/Block.ts +0 -169
  385. package/src/schema/Breadcrumbs.ts +0 -40
  386. package/src/schema/Card.ts +0 -35
  387. package/src/schema/Divider.ts +0 -20
  388. package/src/schema/Element.ts +0 -219
  389. package/src/schema/EmptyState.test.ts +0 -37
  390. package/src/schema/EmptyState.ts +0 -63
  391. package/src/schema/Fieldset.ts +0 -43
  392. package/src/schema/Grid.ts +0 -43
  393. package/src/schema/Group.ts +0 -30
  394. package/src/schema/Heading.ts +0 -39
  395. package/src/schema/Html.ts +0 -67
  396. package/src/schema/Icon.ts +0 -54
  397. package/src/schema/Image.ts +0 -57
  398. package/src/schema/LinkTag.ts +0 -41
  399. package/src/schema/Markdown.ts +0 -85
  400. package/src/schema/MetaTag.ts +0 -41
  401. package/src/schema/RelationTabs.ts +0 -71
  402. package/src/schema/ScriptTag.ts +0 -55
  403. package/src/schema/Section.ts +0 -160
  404. package/src/schema/ServerDataElement.test.ts +0 -140
  405. package/src/schema/ServerDataElement.ts +0 -156
  406. package/src/schema/SlotComponent.test.ts +0 -77
  407. package/src/schema/SlotComponent.ts +0 -71
  408. package/src/schema/Split.ts +0 -50
  409. package/src/schema/Stat.test.ts +0 -118
  410. package/src/schema/Stat.ts +0 -154
  411. package/src/schema/StatsOverview.test.ts +0 -141
  412. package/src/schema/StatsOverview.ts +0 -119
  413. package/src/schema/StyleTag.ts +0 -35
  414. package/src/schema/TableWidget.test.ts +0 -297
  415. package/src/schema/TableWidget.ts +0 -289
  416. package/src/schema/Tabs.ts +0 -79
  417. package/src/schema/Text.ts +0 -58
  418. package/src/schema/UnorderedList.ts +0 -49
  419. package/src/schema/View.test.ts +0 -111
  420. package/src/schema/View.ts +0 -127
  421. package/src/schema/Wizard.ts +0 -220
  422. package/src/schema/containers.test.ts +0 -564
  423. package/src/schema/headTags.test.ts +0 -134
  424. package/src/schema/index.ts +0 -40
  425. package/src/schema/primes.test.ts +0 -269
  426. package/src/schema/resolveSchema.test.ts +0 -379
  427. package/src/schema/resolveSchema.ts +0 -917
  428. package/src/schema/sanitize.ts +0 -58
  429. package/src/search.test.ts +0 -446
  430. package/src/search.ts +0 -178
  431. package/src/sessionFilters.test.ts +0 -375
  432. package/src/sessionFilters.ts +0 -143
  433. package/src/slot-components/index.ts +0 -10
  434. package/src/slot-components/registry.ts +0 -56
  435. package/src/styles/file-upload.css +0 -13
  436. package/src/summarizers/Summarizer.test.ts +0 -84
  437. package/src/summarizers/Summarizer.ts +0 -123
  438. package/src/summarizers/index.ts +0 -11
  439. package/src/theme/base-colors.ts +0 -68
  440. package/src/theme/chart-colors.ts +0 -50
  441. package/src/theme/colors.ts +0 -447
  442. package/src/theme/generate-css.test.ts +0 -139
  443. package/src/theme/generate-css.ts +0 -44
  444. package/src/theme/generate-scale.test.ts +0 -106
  445. package/src/theme/generate-scale.ts +0 -97
  446. package/src/theme/icon-map.ts +0 -42
  447. package/src/theme/index.ts +0 -34
  448. package/src/theme/migrate.test.ts +0 -178
  449. package/src/theme/migrate.ts +0 -81
  450. package/src/theme/presets.ts +0 -135
  451. package/src/theme/radius.ts +0 -18
  452. package/src/theme/resolve.test.ts +0 -238
  453. package/src/theme/resolve.ts +0 -96
  454. package/src/theme/spacing.ts +0 -18
  455. package/src/theme/storage.test.ts +0 -126
  456. package/src/theme/storage.ts +0 -106
  457. package/src/theme/theme-colors.ts +0 -88
  458. package/src/theme/types.ts +0 -125
  459. package/src/uploads/UploadAdapter.ts +0 -35
  460. package/src/uploads/index.ts +0 -2
  461. package/src/uploads/localUpload.test.ts +0 -70
  462. package/src/uploads/localUpload.ts +0 -84
  463. package/src/validation/Validator.ts +0 -49
  464. package/src/validation/index.ts +0 -28
  465. package/src/validation/rules.ts +0 -78
  466. package/src/validation/runValidators.ts +0 -435
  467. package/src/validation/uniqueValidator.test.ts +0 -196
  468. package/src/validation/uniqueValidator.ts +0 -133
  469. package/src/validation/validators.test.ts +0 -268
  470. package/src/vite.test.ts +0 -184
  471. package/src/vite.ts +0 -787
  472. package/src/widgets/index.ts +0 -10
  473. package/src/widgets/registry.ts +0 -45
  474. package/src/widgets.test.ts +0 -592
  475. package/tsconfig.build.json +0 -11
  476. package/tsconfig.json +0 -4
  477. package/tsconfig.test.json +0 -10
  478. package/views/react/Dashboard.tsx +0 -27
  479. package/views/react/Resources/Form.tsx +0 -102
  480. 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
- }