@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.
- package/CHANGELOG.md +91 -0
- package/boost/guidelines.md +566 -0
- package/boost/skills/pilotiq-fields/SKILL.md +47 -0
- package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
- package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
- package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
- package/boost/skills/pilotiq-relations/SKILL.md +47 -0
- package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
- package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
- package/boost/skills/pilotiq-resource/SKILL.md +61 -0
- package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
- package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
- package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
- package/dist/actions/exportFactory.d.ts +10 -0
- package/dist/actions/exportFactory.d.ts.map +1 -1
- package/dist/actions/exportFactory.js +10 -0
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +5 -5
- package/dist/react/index.d.ts +0 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +0 -1
- package/dist/react/index.js.map +1 -1
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +6 -2
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/relations.d.ts.map +1 -1
- package/dist/routes/relations.js +12 -0
- package/dist/routes/relations.js.map +1 -1
- package/package.json +6 -1
- package/.turbo/turbo-build.log +0 -8
- package/CLAUDE.md +0 -265
- package/dist/react/useCollabSeed.d.ts +0 -23
- package/dist/react/useCollabSeed.d.ts.map +0 -1
- package/dist/react/useCollabSeed.js +0 -82
- package/dist/react/useCollabSeed.js.map +0 -1
- package/src/Cluster.test.ts +0 -283
- package/src/Cluster.ts +0 -83
- package/src/Column.test.ts +0 -199
- package/src/Column.ts +0 -710
- package/src/Global.test.ts +0 -367
- package/src/Global.ts +0 -169
- package/src/Page.test.ts +0 -114
- package/src/Page.ts +0 -208
- package/src/Pilotiq.perf.test.ts +0 -252
- package/src/Pilotiq.test.ts +0 -129
- package/src/Pilotiq.ts +0 -1158
- package/src/PilotiqRegistry.ts +0 -36
- package/src/PilotiqServiceProvider.ts +0 -121
- package/src/RelationManager.test.ts +0 -400
- package/src/RelationManager.ts +0 -527
- package/src/RenderHook.test.ts +0 -252
- package/src/RenderHook.ts +0 -242
- package/src/Resource.test.ts +0 -284
- package/src/Resource.ts +0 -526
- package/src/RightPanel.test.ts +0 -202
- package/src/RightPanel.ts +0 -132
- package/src/Tab.test.ts +0 -91
- package/src/Tab.ts +0 -156
- package/src/UserMenuItem.ts +0 -145
- package/src/actions/Action.test.ts +0 -2526
- package/src/actions/Action.ts +0 -1515
- package/src/actions/ActionGroup.test.ts +0 -112
- package/src/actions/ActionGroup.ts +0 -173
- package/src/actions/attachFactory.ts +0 -172
- package/src/actions/bulkFactories.ts +0 -168
- package/src/actions/crudFactories.ts +0 -220
- package/src/actions/exportFactory.ts +0 -215
- package/src/actions/factoryHelpers.ts +0 -177
- package/src/actions/importFactory.ts +0 -243
- package/src/actions/index.ts +0 -17
- package/src/actions/m2mFactories.ts +0 -193
- package/src/actions/relationFactories.ts +0 -372
- package/src/applyPageHooks.test.ts +0 -463
- package/src/applyPageHooks.ts +0 -330
- package/src/authorization.test.ts +0 -483
- package/src/breadcrumbs.test.ts +0 -238
- package/src/cells/coerce.test.ts +0 -85
- package/src/cells/coerce.ts +0 -84
- package/src/clusterPaths.ts +0 -35
- package/src/columns/BadgeColumn.test.ts +0 -54
- package/src/columns/BadgeColumn.ts +0 -32
- package/src/columns/BooleanColumn.test.ts +0 -41
- package/src/columns/BooleanColumn.ts +0 -18
- package/src/columns/ColorColumn.test.ts +0 -37
- package/src/columns/ColorColumn.ts +0 -38
- package/src/columns/IconColumn.test.ts +0 -54
- package/src/columns/IconColumn.ts +0 -37
- package/src/columns/ImageColumn.test.ts +0 -41
- package/src/columns/ImageColumn.ts +0 -28
- package/src/columns/SelectColumn.ts +0 -98
- package/src/columns/TextColumn.test.ts +0 -190
- package/src/columns/TextColumn.ts +0 -20
- package/src/columns/TextInputColumn.ts +0 -68
- package/src/columns/ToggleColumn.ts +0 -46
- package/src/columns/editableColumns.test.ts +0 -238
- package/src/columns/index.ts +0 -9
- package/src/defaultGlobalPages.ts +0 -95
- package/src/defaultPages.test.ts +0 -634
- package/src/defaultPages.ts +0 -617
- package/src/defaultViewPage.test.ts +0 -147
- package/src/elements/Form.test.ts +0 -223
- package/src/elements/Form.ts +0 -416
- package/src/elements/ListTabs.ts +0 -28
- package/src/elements/Table.test.ts +0 -422
- package/src/elements/Table.ts +0 -850
- package/src/elements/TableGroup.test.ts +0 -260
- package/src/elements/TableGroup.ts +0 -334
- package/src/elements/dispatchAction.test.ts +0 -463
- package/src/elements/dispatchAction.ts +0 -355
- package/src/elements/dispatchForm.test.ts +0 -477
- package/src/elements/dispatchForm.ts +0 -1993
- package/src/elements/dispatchTable.test.ts +0 -1514
- package/src/elements/dispatchTable.ts +0 -745
- package/src/elements/index.ts +0 -21
- package/src/entries/BadgeEntry.ts +0 -39
- package/src/entries/CodeEntry.test.ts +0 -40
- package/src/entries/CodeEntry.ts +0 -52
- package/src/entries/ColorEntry.ts +0 -63
- package/src/entries/ComponentEntry.test.ts +0 -173
- package/src/entries/ComponentEntry.ts +0 -95
- package/src/entries/Entry.ts +0 -304
- package/src/entries/IconEntry.ts +0 -49
- package/src/entries/ImageEntry.ts +0 -61
- package/src/entries/KeyValueEntry.ts +0 -47
- package/src/entries/RepeatableEntry.test.ts +0 -239
- package/src/entries/RepeatableEntry.ts +0 -173
- package/src/entries/TextEntry.test.ts +0 -394
- package/src/entries/TextEntry.ts +0 -60
- package/src/entries/index.ts +0 -12
- package/src/entries/leaves.test.ts +0 -306
- package/src/entries/registry.ts +0 -54
- package/src/fields/BuilderField.test.ts +0 -1188
- package/src/fields/BuilderField.ts +0 -605
- package/src/fields/BuilderRelationship.test.ts +0 -811
- package/src/fields/CheckboxField.test.ts +0 -44
- package/src/fields/CheckboxField.ts +0 -27
- package/src/fields/CheckboxListField.test.ts +0 -99
- package/src/fields/CheckboxListField.ts +0 -66
- package/src/fields/ColorPickerField.test.ts +0 -33
- package/src/fields/ColorPickerField.ts +0 -25
- package/src/fields/DateField.ts +0 -54
- package/src/fields/DateTimeField.test.ts +0 -55
- package/src/fields/EmailField.ts +0 -16
- package/src/fields/Field.test.ts +0 -654
- package/src/fields/Field.ts +0 -817
- package/src/fields/FileUploadField.test.ts +0 -143
- package/src/fields/FileUploadField.ts +0 -159
- package/src/fields/HiddenField.test.ts +0 -27
- package/src/fields/HiddenField.ts +0 -28
- package/src/fields/KeyValueField.test.ts +0 -105
- package/src/fields/KeyValueField.ts +0 -55
- package/src/fields/MarkdownField.test.ts +0 -167
- package/src/fields/MarkdownField.ts +0 -162
- package/src/fields/NumberField.ts +0 -33
- package/src/fields/RadioField.test.ts +0 -94
- package/src/fields/RadioField.ts +0 -67
- package/src/fields/RepeaterField.test.ts +0 -1806
- package/src/fields/RepeaterField.ts +0 -939
- package/src/fields/RepeaterRelationship.test.ts +0 -1923
- package/src/fields/RepeaterSimple.test.ts +0 -248
- package/src/fields/RowButton.test.ts +0 -219
- package/src/fields/RowButton.ts +0 -135
- package/src/fields/SelectField.test.ts +0 -192
- package/src/fields/SelectField.ts +0 -235
- package/src/fields/SliderField.test.ts +0 -50
- package/src/fields/SliderField.ts +0 -53
- package/src/fields/SlugField.ts +0 -24
- package/src/fields/TagsInputField.test.ts +0 -154
- package/src/fields/TagsInputField.ts +0 -133
- package/src/fields/TextField.test.ts +0 -213
- package/src/fields/TextField.ts +0 -177
- package/src/fields/TextareaField.test.ts +0 -58
- package/src/fields/TextareaField.ts +0 -59
- package/src/fields/ToggleButtonsField.test.ts +0 -106
- package/src/fields/ToggleButtonsField.ts +0 -59
- package/src/fields/ToggleField.ts +0 -16
- package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
- package/src/fields/optionsResolver.ts +0 -95
- package/src/fields/resolveField.ts +0 -28
- package/src/filters/BooleanFilter.ts +0 -35
- package/src/filters/DateRangeFilter.test.ts +0 -194
- package/src/filters/DateRangeFilter.ts +0 -148
- package/src/filters/Filter.test.ts +0 -268
- package/src/filters/Filter.ts +0 -184
- package/src/filters/FormFilter.test.ts +0 -238
- package/src/filters/FormFilter.ts +0 -215
- package/src/filters/MultiSelectFilter.test.ts +0 -119
- package/src/filters/MultiSelectFilter.ts +0 -78
- package/src/filters/QueryBuilderFilter.test.ts +0 -662
- package/src/filters/QueryBuilderFilter.ts +0 -398
- package/src/filters/SelectFilter.ts +0 -46
- package/src/filters/TernaryFilter.test.ts +0 -160
- package/src/filters/TernaryFilter.ts +0 -72
- package/src/filters/TrashedFilter.test.ts +0 -149
- package/src/filters/TrashedFilter.ts +0 -55
- package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
- package/src/filters/queryBuilder/Constraint.ts +0 -115
- package/src/filters/queryBuilder/DateConstraint.ts +0 -69
- package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
- package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
- package/src/filters/queryBuilder/TextConstraint.ts +0 -64
- package/src/filters/queryBuilder/index.ts +0 -12
- package/src/icons/index.ts +0 -2
- package/src/icons/lucide.ts +0 -204
- package/src/icons/registry.test.ts +0 -56
- package/src/icons/registry.ts +0 -41
- package/src/icons/types.ts +0 -47
- package/src/index.ts +0 -525
- package/src/io/csv.test.ts +0 -142
- package/src/io/csv.ts +0 -170
- package/src/nestedRelationManagerData.test.ts +0 -547
- package/src/notifications/Notification.test.ts +0 -210
- package/src/notifications/Notification.ts +0 -354
- package/src/notifications/broadcast.test.ts +0 -110
- package/src/notifications/broadcast.ts +0 -95
- package/src/notifications/database.test.ts +0 -383
- package/src/notifications/database.ts +0 -398
- package/src/notifications/databaseNotifications.test.ts +0 -187
- package/src/notifications/dispatchNotificationAction.test.ts +0 -341
- package/src/notifications/dispatchNotificationAction.ts +0 -142
- package/src/notifications/flash.test.ts +0 -89
- package/src/notifications/flash.ts +0 -71
- package/src/notifications/index.ts +0 -45
- package/src/notifications/registerBroadcastAuth.test.ts +0 -134
- package/src/notifications/registerBroadcastAuth.ts +0 -100
- package/src/notifications/resolveSavedNotification.test.ts +0 -82
- package/src/notifications/resolveSavedNotification.ts +0 -59
- package/src/notifications/types.ts +0 -93
- package/src/orm/m2mAccessor.ts +0 -66
- package/src/orm/modelDefaults.test.ts +0 -633
- package/src/orm/modelDefaults.ts +0 -666
- package/src/pageData/breadcrumbs.ts +0 -288
- package/src/pageData/forms.ts +0 -578
- package/src/pageData/helpers.ts +0 -857
- package/src/pageData/misc.ts +0 -347
- package/src/pageData/navigation.ts +0 -842
- package/src/pageData/relationPages.ts +0 -1248
- package/src/pageData/relationTabs.ts +0 -286
- package/src/pageData/resourcePages.ts +0 -609
- package/src/pageData.test.ts +0 -1545
- package/src/pageData.ts +0 -341
- package/src/plugins/index.ts +0 -8
- package/src/plugins/themeEditor.test.ts +0 -36
- package/src/plugins/themeEditor.ts +0 -45
- package/src/react/AppShell.tsx +0 -251
- package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
- package/src/react/CollabRoomContext.ts +0 -98
- package/src/react/CollabTextRendererRegistry.ts +0 -102
- package/src/react/CommandPalette.tsx +0 -375
- package/src/react/CurrentUserContext.tsx +0 -50
- package/src/react/CustomPageWrapperGate.tsx +0 -69
- package/src/react/CustomPageWrapperRegistry.ts +0 -45
- package/src/react/FieldFocusReporterRegistry.ts +0 -37
- package/src/react/FieldLabelSlotRegistry.ts +0 -30
- package/src/react/FieldPresenceRegistry.ts +0 -46
- package/src/react/FormCollabBindingRegistry.ts +0 -242
- package/src/react/FormStateContext.tsx +0 -591
- package/src/react/HeadHooks.tsx +0 -126
- package/src/react/MarkdownEditorRegistry.test.ts +0 -38
- package/src/react/MarkdownEditorRegistry.ts +0 -107
- package/src/react/NotificationActionStrip.tsx +0 -263
- package/src/react/NotificationBell.tsx +0 -426
- package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
- package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
- package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
- package/src/react/PendingSuggestionsContext.tsx +0 -172
- package/src/react/RecordWrapperGate.tsx +0 -58
- package/src/react/RecordWrapperRegistry.ts +0 -39
- package/src/react/RenderHookSlot.tsx +0 -32
- package/src/react/RightSidebar.tsx +0 -257
- package/src/react/RightSidebarContext.tsx +0 -234
- package/src/react/RightSidebarTrigger.tsx +0 -53
- package/src/react/RowCoordsContext.tsx +0 -23
- package/src/react/SchemaRenderer.tsx +0 -549
- package/src/react/SearchTrigger.tsx +0 -46
- package/src/react/ThemeProvider.tsx +0 -93
- package/src/react/ThemeSettingsPage.tsx +0 -579
- package/src/react/ThemeToggle.tsx +0 -20
- package/src/react/Toaster.tsx +0 -158
- package/src/react/UserMenu.tsx +0 -196
- package/src/react/WidgetDataContext.tsx +0 -157
- package/src/react/cells/EditableCell.tsx +0 -389
- package/src/react/component-slots.test.ts +0 -103
- package/src/react/component-slots.ts +0 -116
- package/src/react/fieldJsHandler.test.ts +0 -166
- package/src/react/fieldJsHandler.ts +0 -79
- package/src/react/fields/BuilderInput.tsx +0 -1078
- package/src/react/fields/CheckboxInput.tsx +0 -39
- package/src/react/fields/CheckboxListInput.tsx +0 -102
- package/src/react/fields/ColorInput.tsx +0 -71
- package/src/react/fields/DateFieldInput.tsx +0 -70
- package/src/react/fields/DateTimeInput.tsx +0 -62
- package/src/react/fields/FieldShell.tsx +0 -348
- package/src/react/fields/FileUploadInput.tsx +0 -639
- package/src/react/fields/HiddenInput.tsx +0 -17
- package/src/react/fields/KeyValueInput.tsx +0 -230
- package/src/react/fields/MarkdownInput.tsx +0 -560
- package/src/react/fields/RadioInput.tsx +0 -81
- package/src/react/fields/RepeaterInput.test.ts +0 -116
- package/src/react/fields/RepeaterInput.tsx +0 -1420
- package/src/react/fields/SelectFieldInput.tsx +0 -280
- package/src/react/fields/SliderInput.tsx +0 -81
- package/src/react/fields/TagsInput.tsx +0 -283
- package/src/react/fields/TextLikeInput.tsx +0 -256
- package/src/react/fields/ToggleButtonsInput.tsx +0 -60
- package/src/react/fields/ToggleFieldInput.tsx +0 -56
- package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
- package/src/react/fields/relationshipRenameDispatch.ts +0 -97
- package/src/react/fields/repeaterReconcile.test.ts +0 -114
- package/src/react/fields/repeaterReconcile.ts +0 -104
- package/src/react/fields/rowChromeButton.tsx +0 -336
- package/src/react/fields/rowState.ts +0 -106
- package/src/react/fields/syncRowGates.test.ts +0 -202
- package/src/react/fields/syncRowGates.ts +0 -66
- package/src/react/fields/textInputControls.tsx +0 -238
- package/src/react/fields/useRowReorderDnd.ts +0 -78
- package/src/react/formStateHelpers.test.ts +0 -508
- package/src/react/formStateHelpers.ts +0 -381
- package/src/react/hooks/use-mobile.ts +0 -19
- package/src/react/icon-context.tsx +0 -60
- package/src/react/index.ts +0 -195
- package/src/react/layouts/SidebarLayout.tsx +0 -250
- package/src/react/layouts/TopbarLayout.tsx +0 -258
- package/src/react/navigate.tsx +0 -37
- package/src/react/onProviderSynced.test.ts +0 -90
- package/src/react/parseRecordEditUrl.test.ts +0 -122
- package/src/react/parseRecordEditUrl.ts +0 -94
- package/src/react/persistedState.ts +0 -40
- package/src/react/registry.ts +0 -48
- package/src/react/right-panel-registry.tsx +0 -47
- package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
- package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
- package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
- package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
- package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
- package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
- package/src/react/schemaRenderer/action/buttons.tsx +0 -99
- package/src/react/schemaRenderer/action/helpers.ts +0 -140
- package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
- package/src/react/schemaRenderer/columnFormat.ts +0 -65
- package/src/react/schemaRenderer/constants.ts +0 -50
- package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
- package/src/react/schemaRenderer/form/renderField.tsx +0 -511
- package/src/react/schemaRenderer/helpers.tsx +0 -81
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
- package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
- package/src/react/schemaRenderer/table/filters.tsx +0 -1233
- package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
- package/src/react/schemaRenderer/table/links.tsx +0 -112
- package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
- package/src/react/schemaRenderer/table/url.tsx +0 -143
- package/src/react/theme-preview/apply.ts +0 -99
- package/src/react/theme-preview/build-html.ts +0 -436
- package/src/react/ui/button.tsx +0 -51
- package/src/react/ui/calendar.tsx +0 -67
- package/src/react/ui/checkbox.tsx +0 -29
- package/src/react/ui/dialog.tsx +0 -108
- package/src/react/ui/dropdown-menu.tsx +0 -97
- package/src/react/ui/input.tsx +0 -20
- package/src/react/ui/label.tsx +0 -21
- package/src/react/ui/popover.tsx +0 -50
- package/src/react/ui/select.tsx +0 -169
- package/src/react/ui/separator.tsx +0 -25
- package/src/react/ui/sheet.tsx +0 -136
- package/src/react/ui/sidebar.tsx +0 -723
- package/src/react/ui/skeleton.tsx +0 -13
- package/src/react/ui/slider.tsx +0 -34
- package/src/react/ui/switch.tsx +0 -28
- package/src/react/ui/table.tsx +0 -105
- package/src/react/ui/tabs.tsx +0 -63
- package/src/react/ui/textarea.tsx +0 -18
- package/src/react/ui/tooltip.tsx +0 -64
- package/src/react/useCollabSeed.ts +0 -86
- package/src/react/useResizableWidth.ts +0 -139
- package/src/react/utils.ts +0 -6
- package/src/react/widgetRegistry.test.ts +0 -43
- package/src/react/widgetRegistry.ts +0 -50
- package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
- package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
- package/src/react/widgets/ViewRenderer.tsx +0 -71
- package/src/relationManagerData.test.ts +0 -1595
- package/src/richtext/index.ts +0 -8
- package/src/richtext/registry.ts +0 -89
- package/src/routes/globals.ts +0 -148
- package/src/routes/guard.test.ts +0 -325
- package/src/routes/helpers.ts +0 -700
- package/src/routes/pages.ts +0 -175
- package/src/routes/panel.ts +0 -204
- package/src/routes/relations.ts +0 -1227
- package/src/routes/resources.ts +0 -781
- package/src/routes/theme.ts +0 -91
- package/src/routes-nested-relations.test.ts +0 -676
- package/src/routes-relations.test.ts +0 -972
- package/src/routes.test.ts +0 -2027
- package/src/routes.ts +0 -303
- package/src/schema/Alert.test.ts +0 -109
- package/src/schema/Alert.ts +0 -131
- package/src/schema/Block.ts +0 -169
- package/src/schema/Breadcrumbs.ts +0 -40
- package/src/schema/Card.ts +0 -35
- package/src/schema/Divider.ts +0 -20
- package/src/schema/Element.ts +0 -219
- package/src/schema/EmptyState.test.ts +0 -37
- package/src/schema/EmptyState.ts +0 -63
- package/src/schema/Fieldset.ts +0 -43
- package/src/schema/Grid.ts +0 -43
- package/src/schema/Group.ts +0 -30
- package/src/schema/Heading.ts +0 -39
- package/src/schema/Html.ts +0 -67
- package/src/schema/Icon.ts +0 -54
- package/src/schema/Image.ts +0 -57
- package/src/schema/LinkTag.ts +0 -41
- package/src/schema/Markdown.ts +0 -85
- package/src/schema/MetaTag.ts +0 -41
- package/src/schema/RelationTabs.ts +0 -71
- package/src/schema/ScriptTag.ts +0 -55
- package/src/schema/Section.ts +0 -160
- package/src/schema/ServerDataElement.test.ts +0 -140
- package/src/schema/ServerDataElement.ts +0 -156
- package/src/schema/SlotComponent.test.ts +0 -77
- package/src/schema/SlotComponent.ts +0 -71
- package/src/schema/Split.ts +0 -50
- package/src/schema/Stat.test.ts +0 -118
- package/src/schema/Stat.ts +0 -154
- package/src/schema/StatsOverview.test.ts +0 -141
- package/src/schema/StatsOverview.ts +0 -119
- package/src/schema/StyleTag.ts +0 -35
- package/src/schema/TableWidget.test.ts +0 -297
- package/src/schema/TableWidget.ts +0 -289
- package/src/schema/Tabs.ts +0 -79
- package/src/schema/Text.ts +0 -58
- package/src/schema/UnorderedList.ts +0 -49
- package/src/schema/View.test.ts +0 -111
- package/src/schema/View.ts +0 -127
- package/src/schema/Wizard.ts +0 -220
- package/src/schema/containers.test.ts +0 -564
- package/src/schema/headTags.test.ts +0 -134
- package/src/schema/index.ts +0 -40
- package/src/schema/primes.test.ts +0 -269
- package/src/schema/resolveSchema.test.ts +0 -379
- package/src/schema/resolveSchema.ts +0 -917
- package/src/schema/sanitize.ts +0 -58
- package/src/search.test.ts +0 -446
- package/src/search.ts +0 -178
- package/src/sessionFilters.test.ts +0 -375
- package/src/sessionFilters.ts +0 -143
- package/src/slot-components/index.ts +0 -10
- package/src/slot-components/registry.ts +0 -56
- package/src/styles/file-upload.css +0 -13
- package/src/summarizers/Summarizer.test.ts +0 -84
- package/src/summarizers/Summarizer.ts +0 -123
- package/src/summarizers/index.ts +0 -11
- package/src/theme/base-colors.ts +0 -68
- package/src/theme/chart-colors.ts +0 -50
- package/src/theme/colors.ts +0 -447
- package/src/theme/generate-css.test.ts +0 -139
- package/src/theme/generate-css.ts +0 -44
- package/src/theme/generate-scale.test.ts +0 -106
- package/src/theme/generate-scale.ts +0 -97
- package/src/theme/icon-map.ts +0 -42
- package/src/theme/index.ts +0 -34
- package/src/theme/migrate.test.ts +0 -178
- package/src/theme/migrate.ts +0 -81
- package/src/theme/presets.ts +0 -135
- package/src/theme/radius.ts +0 -18
- package/src/theme/resolve.test.ts +0 -238
- package/src/theme/resolve.ts +0 -96
- package/src/theme/spacing.ts +0 -18
- package/src/theme/storage.test.ts +0 -126
- package/src/theme/storage.ts +0 -106
- package/src/theme/theme-colors.ts +0 -88
- package/src/theme/types.ts +0 -125
- package/src/uploads/UploadAdapter.ts +0 -35
- package/src/uploads/index.ts +0 -2
- package/src/uploads/localUpload.test.ts +0 -70
- package/src/uploads/localUpload.ts +0 -84
- package/src/validation/Validator.ts +0 -49
- package/src/validation/index.ts +0 -28
- package/src/validation/rules.ts +0 -78
- package/src/validation/runValidators.ts +0 -435
- package/src/validation/uniqueValidator.test.ts +0 -196
- package/src/validation/uniqueValidator.ts +0 -133
- package/src/validation/validators.test.ts +0 -268
- package/src/vite.test.ts +0 -184
- package/src/vite.ts +0 -787
- package/src/widgets/index.ts +0 -10
- package/src/widgets/registry.ts +0 -45
- package/src/widgets.test.ts +0 -592
- package/tsconfig.build.json +0 -11
- package/tsconfig.json +0 -4
- package/tsconfig.test.json +0 -10
- package/views/react/Dashboard.tsx +0 -27
- package/views/react/Resources/Form.tsx +0 -102
- package/views/react/Resources/Index.tsx +0 -49
package/src/routes/relations.ts
DELETED
|
@@ -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
|
-
}
|