@pilotiq/pilotiq 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +142 -0
- package/CLAUDE.md +59 -3
- package/dist/Pilotiq.d.ts +83 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +39 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/actions/Action.d.ts +27 -99
- package/dist/actions/Action.d.ts.map +1 -1
- package/dist/actions/Action.js +52 -754
- package/dist/actions/Action.js.map +1 -1
- package/dist/actions/bulkFactories.d.ts +46 -0
- package/dist/actions/bulkFactories.d.ts.map +1 -0
- package/dist/actions/bulkFactories.js +144 -0
- package/dist/actions/bulkFactories.js.map +1 -0
- package/dist/actions/crudFactories.d.ts +94 -0
- package/dist/actions/crudFactories.d.ts.map +1 -0
- package/dist/actions/crudFactories.js +209 -0
- package/dist/actions/crudFactories.js.map +1 -0
- package/dist/actions/factoryHelpers.d.ts +108 -0
- package/dist/actions/factoryHelpers.d.ts.map +1 -0
- package/dist/actions/factoryHelpers.js +138 -0
- package/dist/actions/factoryHelpers.js.map +1 -0
- package/dist/actions/m2mFactories.d.ts +47 -0
- package/dist/actions/m2mFactories.d.ts.map +1 -0
- package/dist/actions/m2mFactories.js +173 -0
- package/dist/actions/m2mFactories.js.map +1 -0
- package/dist/actions/relationFactories.d.ts +93 -0
- package/dist/actions/relationFactories.d.ts.map +1 -0
- package/dist/actions/relationFactories.js +321 -0
- package/dist/actions/relationFactories.js.map +1 -0
- package/dist/elements/dispatchForm.js +1 -1
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.js +1 -1
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +31 -0
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +25 -0
- package/dist/fields/Field.js.map +1 -1
- package/dist/pageData/breadcrumbs.d.ts +42 -0
- package/dist/pageData/breadcrumbs.d.ts.map +1 -0
- package/dist/pageData/breadcrumbs.js +172 -0
- package/dist/pageData/breadcrumbs.js.map +1 -0
- package/dist/pageData/forms.d.ts +137 -0
- package/dist/pageData/forms.d.ts.map +1 -0
- package/dist/pageData/forms.js +427 -0
- package/dist/pageData/forms.js.map +1 -0
- package/dist/pageData/helpers.d.ts +239 -0
- package/dist/pageData/helpers.d.ts.map +1 -0
- package/dist/pageData/helpers.js +703 -0
- package/dist/pageData/helpers.js.map +1 -0
- package/dist/pageData/misc.d.ts +76 -0
- package/dist/pageData/misc.d.ts.map +1 -0
- package/dist/pageData/misc.js +263 -0
- package/dist/pageData/misc.js.map +1 -0
- package/dist/pageData/navigation.d.ts +292 -0
- package/dist/pageData/navigation.d.ts.map +1 -0
- package/dist/pageData/navigation.js +591 -0
- package/dist/pageData/navigation.js.map +1 -0
- package/dist/pageData/relationPages.d.ts +172 -0
- package/dist/pageData/relationPages.d.ts.map +1 -0
- package/dist/pageData/relationPages.js +867 -0
- package/dist/pageData/relationPages.js.map +1 -0
- package/dist/pageData/relationTabs.d.ts +65 -0
- package/dist/pageData/relationTabs.d.ts.map +1 -0
- package/dist/pageData/relationTabs.js +258 -0
- package/dist/pageData/relationTabs.js.map +1 -0
- package/dist/pageData/resourcePages.d.ts +48 -0
- package/dist/pageData/resourcePages.d.ts.map +1 -0
- package/dist/pageData/resourcePages.js +504 -0
- package/dist/pageData/resourcePages.js.map +1 -0
- package/dist/pageData.d.ts +12 -792
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +24 -3797
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +8 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +11 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
- package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
- package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
- package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
- package/dist/react/CollabRoomContext.d.ts +37 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -0
- package/dist/react/CollabRoomContext.js +12 -0
- package/dist/react/CollabRoomContext.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
- package/dist/react/FormCollabBindingRegistry.js +14 -0
- package/dist/react/FormCollabBindingRegistry.js.map +1 -0
- package/dist/react/RecordWrapperGate.d.ts +25 -0
- package/dist/react/RecordWrapperGate.d.ts.map +1 -0
- package/dist/react/RecordWrapperGate.js +30 -0
- package/dist/react/RecordWrapperGate.js.map +1 -0
- package/dist/react/RecordWrapperRegistry.d.ts +31 -0
- package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
- package/dist/react/RecordWrapperRegistry.js +15 -0
- package/dist/react/RecordWrapperRegistry.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts +17 -23
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +71 -3647
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/component-slots.d.ts +103 -0
- package/dist/react/component-slots.d.ts.map +1 -0
- package/dist/react/component-slots.js +18 -0
- package/dist/react/component-slots.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +21 -117
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +1 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +22 -127
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/rowState.d.ts +40 -0
- package/dist/react/fields/rowState.d.ts.map +1 -0
- package/dist/react/fields/rowState.js +60 -0
- package/dist/react/fields/rowState.js.map +1 -0
- package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
- package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
- package/dist/react/fields/useRowReorderDnd.js +51 -0
- package/dist/react/fields/useRowReorderDnd.js.map +1 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +8 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
- package/dist/react/layouts/SidebarLayout.js +10 -2
- package/dist/react/layouts/SidebarLayout.js.map +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
- package/dist/react/layouts/TopbarLayout.js +19 -11
- package/dist/react/layouts/TopbarLayout.js.map +1 -1
- package/dist/react/parseRecordEditUrl.d.ts +29 -0
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
- package/dist/react/parseRecordEditUrl.js +25 -0
- package/dist/react/parseRecordEditUrl.js.map +1 -0
- package/dist/react/persistedState.d.ts +19 -0
- package/dist/react/persistedState.d.ts.map +1 -0
- package/dist/react/persistedState.js +51 -0
- package/dist/react/persistedState.js.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
- package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
- package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
- package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.js +147 -0
- package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
- package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
- package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.js +74 -0
- package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.js +126 -0
- package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.js +102 -0
- package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.js +76 -0
- package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
- package/dist/react/schemaRenderer/constants.d.ts +8 -0
- package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
- package/dist/react/schemaRenderer/constants.js +45 -0
- package/dist/react/schemaRenderer/constants.js.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.js +239 -0
- package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
- package/dist/react/schemaRenderer/helpers.d.ts +32 -0
- package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/helpers.js +52 -0
- package/dist/react/schemaRenderer/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
- package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/filters.js +497 -0
- package/dist/react/schemaRenderer/table/filters.js.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.js +172 -0
- package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
- package/dist/react/schemaRenderer/table/links.d.ts +42 -0
- package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/links.js +55 -0
- package/dist/react/schemaRenderer/table/links.js.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
- package/dist/react/schemaRenderer/table/url.d.ts +41 -0
- package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/url.js +114 -0
- package/dist/react/schemaRenderer/table/url.js.map +1 -0
- package/dist/routes/globals.d.ts +13 -0
- package/dist/routes/globals.d.ts.map +1 -0
- package/dist/routes/globals.js +131 -0
- package/dist/routes/globals.js.map +1 -0
- package/dist/routes/helpers.d.ts +217 -0
- package/dist/routes/helpers.d.ts.map +1 -0
- package/dist/routes/helpers.js +498 -0
- package/dist/routes/helpers.js.map +1 -0
- package/dist/routes/pages.d.ts +15 -0
- package/dist/routes/pages.d.ts.map +1 -0
- package/dist/routes/pages.js +145 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/routes/panel.d.ts +19 -0
- package/dist/routes/panel.d.ts.map +1 -0
- package/dist/routes/panel.js +191 -0
- package/dist/routes/panel.js.map +1 -0
- package/dist/routes/relations.d.ts +21 -0
- package/dist/routes/relations.d.ts.map +1 -0
- package/dist/routes/relations.js +1239 -0
- package/dist/routes/relations.js.map +1 -0
- package/dist/routes/resources.d.ts +28 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +741 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/theme.d.ts +12 -0
- package/dist/routes/theme.d.ts.map +1 -0
- package/dist/routes/theme.js +82 -0
- package/dist/routes/theme.js.map +1 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +64 -3078
- package/dist/routes.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -5
- package/dist/vite.js.map +1 -1
- package/package.json +2 -1
- package/src/Pilotiq.ts +95 -0
- package/src/actions/Action.ts +79 -723
- package/src/actions/bulkFactories.ts +168 -0
- package/src/actions/crudFactories.ts +220 -0
- package/src/actions/factoryHelpers.ts +177 -0
- package/src/actions/m2mFactories.ts +193 -0
- package/src/actions/relationFactories.ts +372 -0
- package/src/elements/dispatchForm.ts +1 -1
- package/src/elements/dispatchTable.ts +1 -1
- package/src/fields/Field.ts +39 -0
- package/src/pageData/breadcrumbs.ts +288 -0
- package/src/pageData/forms.ts +578 -0
- package/src/pageData/helpers.ts +764 -0
- package/src/pageData/misc.ts +347 -0
- package/src/pageData/navigation.ts +779 -0
- package/src/pageData/relationPages.ts +1246 -0
- package/src/pageData/relationTabs.ts +286 -0
- package/src/pageData/resourcePages.ts +593 -0
- package/src/pageData.ts +122 -4731
- package/src/react/AppShell.tsx +27 -1
- package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
- package/src/react/CollabRoomContext.ts +42 -0
- package/src/react/FormCollabBindingRegistry.ts +72 -0
- package/src/react/RecordWrapperGate.tsx +40 -0
- package/src/react/RecordWrapperRegistry.ts +39 -0
- package/src/react/SchemaRenderer.tsx +230 -6479
- package/src/react/component-slots.test.ts +103 -0
- package/src/react/component-slots.ts +116 -0
- package/src/react/fields/BuilderInput.tsx +29 -117
- package/src/react/fields/MarkdownInput.tsx +0 -1
- package/src/react/fields/RepeaterInput.tsx +29 -130
- package/src/react/fields/rowState.ts +106 -0
- package/src/react/fields/useRowReorderDnd.ts +78 -0
- package/src/react/index.ts +38 -0
- package/src/react/layouts/SidebarLayout.tsx +39 -28
- package/src/react/layouts/TopbarLayout.tsx +70 -57
- package/src/react/parseRecordEditUrl.test.ts +75 -0
- package/src/react/parseRecordEditUrl.ts +55 -0
- package/src/react/persistedState.ts +40 -0
- package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
- package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
- package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
- package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
- package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
- package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
- package/src/react/schemaRenderer/action/buttons.tsx +99 -0
- package/src/react/schemaRenderer/action/helpers.ts +140 -0
- package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
- package/src/react/schemaRenderer/columnFormat.ts +65 -0
- package/src/react/schemaRenderer/constants.ts +50 -0
- package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
- package/src/react/schemaRenderer/form/renderField.tsx +511 -0
- package/src/react/schemaRenderer/helpers.tsx +81 -0
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
- package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
- package/src/react/schemaRenderer/table/filters.tsx +1233 -0
- package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
- package/src/react/schemaRenderer/table/links.tsx +112 -0
- package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
- package/src/react/schemaRenderer/table/url.tsx +143 -0
- package/src/routes/globals.ts +154 -0
- package/src/routes/helpers.ts +668 -0
- package/src/routes/pages.ts +173 -0
- package/src/routes/panel.ts +204 -0
- package/src/routes/relations.ts +1219 -0
- package/src/routes/resources.ts +786 -0
- package/src/routes/theme.ts +109 -0
- package/src/routes.test.ts +1 -1
- package/src/routes.ts +64 -3176
- package/src/schema/TableWidget.test.ts +2 -2
- package/src/theme/migrate.test.ts +178 -0
- package/src/vite.test.ts +184 -0
- package/src/vite.ts +26 -4
package/src/routes.ts
CHANGED
|
@@ -1,598 +1,21 @@
|
|
|
1
1
|
import type { Router } from '@rudderjs/router'
|
|
2
|
-
import type { AppRequest, AppResponse } from '@rudderjs/contracts'
|
|
3
|
-
import { view } from '@rudderjs/view'
|
|
4
2
|
import type { Pilotiq } from './Pilotiq.js'
|
|
5
3
|
import { Form } from './elements/Form.js'
|
|
6
|
-
import { resolveSchema, type SchemaContext } from './schema/resolveSchema.js'
|
|
7
4
|
import { dispatchFormSubmit, findForms, selectForm } from './elements/dispatchForm.js'
|
|
8
|
-
import {
|
|
9
|
-
import { flashNotifications } from './notifications/flash.js'
|
|
10
|
-
import {
|
|
11
|
-
listFiltersKey,
|
|
12
|
-
readPersistedListQuery,
|
|
13
|
-
writePersistedListQuery,
|
|
14
|
-
readPersistedLastTab,
|
|
15
|
-
writePersistedLastTab,
|
|
16
|
-
encodePersistedQuery,
|
|
17
|
-
} from './sessionFilters.js'
|
|
18
|
-
import {
|
|
19
|
-
panelInfo, callPageSchema, tagFormActions, tagActionDispatch,
|
|
20
|
-
dashboardData, resourceIndexData, resourceTableData,
|
|
21
|
-
resourceCreateData, resourceEditData,
|
|
22
|
-
resourceViewData, resourceRecordPageData,
|
|
23
|
-
globalEditData, globalViewData, customPageData,
|
|
24
|
-
formStateData, type FormStateScope,
|
|
25
|
-
formWizardData,
|
|
26
|
-
formCreateOptionData,
|
|
27
|
-
mentionResolveData,
|
|
28
|
-
searchData,
|
|
29
|
-
relationManagerData, findRelatedResource, safeManagerPolicy,
|
|
30
|
-
resolveRelationChain, type ResolvedChain,
|
|
31
|
-
widgetData, type WidgetScope,
|
|
32
|
-
} from './pageData.js'
|
|
33
|
-
import {
|
|
34
|
-
listForUser as listDatabaseNotifications,
|
|
35
|
-
findOneForUser as findDatabaseNotificationForUser,
|
|
36
|
-
markAsRead as markDatabaseNotificationAsRead,
|
|
37
|
-
markAsUnread as markDatabaseNotificationAsUnread,
|
|
38
|
-
markAllAsRead as markAllDatabaseNotificationsAsRead,
|
|
39
|
-
} from './notifications/database.js'
|
|
40
|
-
import { dispatchNotificationAction } from './notifications/dispatchNotificationAction.js'
|
|
41
|
-
import { registerBroadcastAuth } from './notifications/registerBroadcastAuth.js'
|
|
42
|
-
import {
|
|
43
|
-
RelationManager, RESERVED_RELATIONSHIP_TOKENS,
|
|
44
|
-
normalizeRelationMode,
|
|
45
|
-
type RelationMode,
|
|
46
|
-
} from './RelationManager.js'
|
|
47
|
-
import {
|
|
48
|
-
modelSave, modelLoadRecord, findRecord, getPrimaryKey, getRelationType,
|
|
49
|
-
getMorphRelationDescriptor, computeMorphPayload,
|
|
50
|
-
} from './orm/modelDefaults.js'
|
|
5
|
+
import { RESERVED_RELATIONSHIP_TOKENS } from './RelationManager.js'
|
|
51
6
|
import { Table } from './elements/Table.js'
|
|
52
7
|
import { Column } from './Column.js'
|
|
53
|
-
import { coerceCellValue, CellCoerceError } from './cells/coerce.js'
|
|
54
|
-
import type { ThemeConfig } from './theme/types.js'
|
|
55
|
-
import { presets } from './theme/presets.js'
|
|
56
|
-
import { baseColors } from './theme/base-colors.js'
|
|
57
|
-
import { HUE_NAMES } from './theme/colors.js'
|
|
58
|
-
import { migrateThemeOverrides } from './theme/migrate.js'
|
|
59
|
-
import { radiusMap } from './theme/radius.js'
|
|
60
|
-
import { resourceBasePath, globalBasePath, pageBasePath } from './clusterPaths.js'
|
|
61
8
|
import type { ClusterClass } from './Cluster.js'
|
|
62
9
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Read the request body as a `Record<string, unknown>`. The hono adapter
|
|
75
|
-
* auto-parses JSON, but `application/x-www-form-urlencoded` and
|
|
76
|
-
* `multipart/form-data` need a manual fall-through to Hono's own parser.
|
|
77
|
-
*/
|
|
78
|
-
async function readFormBody(req: AppRequest): Promise<Record<string, unknown>> {
|
|
79
|
-
if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
|
|
80
|
-
return { ...(req.body as Record<string, unknown>) }
|
|
81
|
-
}
|
|
82
|
-
const raw = req.raw as { req?: { parseBody?: () => Promise<Record<string, unknown>> } } | undefined
|
|
83
|
-
if (raw?.req?.parseBody) {
|
|
84
|
-
try {
|
|
85
|
-
const parsed = await raw.req.parseBody()
|
|
86
|
-
return parsed && typeof parsed === 'object' ? { ...parsed } : {}
|
|
87
|
-
} catch {
|
|
88
|
-
return {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return {}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Normalize a user-supplied redirect URL. Returns absolute URLs and
|
|
96
|
-
* scheme-prefixed URLs unchanged. Bare relative paths (no leading `/`)
|
|
97
|
-
* are joined under the panel's `basePath` — without this, the browser
|
|
98
|
-
* resolves the redirect against the current request URL and produces
|
|
99
|
-
* paths like `/admin/articles/{id}/articles/{id}/edit`.
|
|
100
|
-
*
|
|
101
|
-
* `getRedirectUrl` page hooks and `Form.redirectAfterSave` callbacks
|
|
102
|
-
* are user-authored; this protects the framework against the common
|
|
103
|
-
* authoring slip while keeping absolute URLs (the documented form)
|
|
104
|
-
* working as-is.
|
|
105
|
-
*/
|
|
106
|
-
function normalizeRedirect(url: string | undefined, basePath: string): string | undefined {
|
|
107
|
-
if (!url) return undefined
|
|
108
|
-
if (url.startsWith('/')) return url
|
|
109
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url // http(s):, mailto:, etc.
|
|
110
|
-
const trimmedBase = basePath.replace(/\/$/, '')
|
|
111
|
-
return `${trimmedBase}/${url}`
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Strip framework meta keys (`_formId`, `_method`, `_continueCreate`)
|
|
115
|
-
* from a parsed body. `continueCreate` mirrors the secondary
|
|
116
|
-
* "Create & create another" submit on `CreatePage`: when `'1'`, the
|
|
117
|
-
* create POST handler routes the redirect back to the create URL
|
|
118
|
-
* instead of the new record's edit page. */
|
|
119
|
-
function splitMeta(body: Record<string, unknown>): {
|
|
120
|
-
values: Record<string, unknown>
|
|
121
|
-
formId: string | undefined
|
|
122
|
-
continueCreate: boolean
|
|
123
|
-
} {
|
|
124
|
-
const { _formId, _method: _omitMethod, _continueCreate, ...rest } = body
|
|
125
|
-
return {
|
|
126
|
-
values: rest,
|
|
127
|
-
formId: typeof _formId === 'string' ? _formId : undefined,
|
|
128
|
-
continueCreate: _continueCreate === '1' || _continueCreate === 1 || _continueCreate === true,
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Strip control characters (`"\\\r\n`) from a download filename so
|
|
133
|
-
* the `Content-Disposition: attachment; filename="…"` header stays
|
|
134
|
-
* unbreakable. Defends against a handler that returns a hostile
|
|
135
|
-
* filename string. Empty fallback `'export'`. */
|
|
136
|
-
function sanitizeFilename(name: string): string {
|
|
137
|
-
const cleaned = (name ?? '').replace(/[\r\n"\\]/g, '').trim()
|
|
138
|
-
return cleaned.length > 0 ? cleaned : 'export'
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Write an `Action`-handler download envelope as the response. Sets
|
|
142
|
-
* `Content-Type` + `Content-Disposition: attachment` and ends with
|
|
143
|
-
* the body. Mutually exclusive with redirect — call sites consult
|
|
144
|
-
* `result.download` first. */
|
|
145
|
-
function sendDownload(
|
|
146
|
-
res: AppResponse,
|
|
147
|
-
env: { filename: string; contentType: string; body: string },
|
|
148
|
-
): void {
|
|
149
|
-
res.header('Content-Type', env.contentType)
|
|
150
|
-
res.header('Content-Disposition', `attachment; filename="${sanitizeFilename(env.filename)}"`)
|
|
151
|
-
res.send(env.body)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Plan #10 — send a 403 response. Branches on `Accept: application/json`
|
|
155
|
-
* the same way the action / form dispatch paths do. Used by every route
|
|
156
|
-
* after a `Resource.canX(...)` check fails. We deliberately do NOT
|
|
157
|
-
* redirect to login: 403 means "authenticated but not allowed"; the
|
|
158
|
-
* 401-unauthenticated case is `Pilotiq.guard()`'s job. */
|
|
159
|
-
function forbidden(res: AppResponse, json: boolean): unknown {
|
|
160
|
-
res.status(403)
|
|
161
|
-
if (json) return res.json({ ok: false, error: 'Forbidden' })
|
|
162
|
-
return res.send('Forbidden')
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Extract a user-facing message from a thrown value inside an editable
|
|
166
|
-
* column's beforeStateUpdated / afterStateUpdated hook. Stamped under
|
|
167
|
-
* the reserved `_cell` key in the 422 response. */
|
|
168
|
-
function cellHookErrorMessage(err: unknown): string {
|
|
169
|
-
if (err instanceof Error && err.message) return err.message
|
|
170
|
-
if (typeof err === 'string' && err.length > 0) return err
|
|
171
|
-
return 'Update halted'
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Run a `canX(...)` predicate, treating throws as `false`. The predicate
|
|
175
|
-
* is user-authored and we want a flaky check to fail closed (deny) rather
|
|
176
|
-
* than 500 the page. */
|
|
177
|
-
async function checkPolicy(fn: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
178
|
-
try { return Boolean(await fn()) } catch { return false }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function policyAccess(
|
|
182
|
-
owner: {
|
|
183
|
-
canAccess: (user: unknown) => boolean | Promise<boolean>
|
|
184
|
-
cluster?: { canAccess: (user: unknown) => boolean | Promise<boolean> }
|
|
185
|
-
},
|
|
186
|
-
user: unknown,
|
|
187
|
-
): Promise<boolean> {
|
|
188
|
-
const [ownerOk, clusterOk] = await Promise.all([
|
|
189
|
-
checkPolicy(() => owner.canAccess(user)),
|
|
190
|
-
owner.cluster
|
|
191
|
-
? checkPolicy(() => owner.cluster!.canAccess(user))
|
|
192
|
-
: Promise.resolve(true),
|
|
193
|
-
])
|
|
194
|
-
return ownerOk && clusterOk
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Locate an action by name in a resolved page schema. Looks at both
|
|
199
|
-
* page-level actions (`findActions`) AND row-scoped extraItemActions on
|
|
200
|
-
* Repeater/Builder fields (`findRowExtraActions`). When the match is
|
|
201
|
-
* row-scoped, also returns the parent field reference and the form
|
|
202
|
-
* schema array — the dispatcher uses both to coerce the form body and
|
|
203
|
-
* navigate to the right row when stamping `ctx.row`.
|
|
204
|
-
*
|
|
205
|
-
* Page-level matches win when a page-level + row-scoped action share the
|
|
206
|
-
* same name (page-level is strictly more privileged: it has access to
|
|
207
|
-
* the full form, not just one row). The collision is undocumented
|
|
208
|
-
* behavior — authors should use distinct names.
|
|
209
|
-
*/
|
|
210
|
-
function resolveDispatchTarget(
|
|
211
|
-
elements: import('./schema/Element.js').Element[],
|
|
212
|
-
actionName: string,
|
|
213
|
-
): {
|
|
214
|
-
action: import('./actions/Action.js').Action
|
|
215
|
-
rowField?: import('./fields/RepeaterField.js').RepeaterField | import('./fields/BuilderField.js').BuilderField
|
|
216
|
-
formSchema?: import('./schema/Element.js').Element[]
|
|
217
|
-
} | null {
|
|
218
|
-
const pageLevel = findActions(elements).find(a => a.name === actionName)
|
|
219
|
-
if (pageLevel) return { action: pageLevel }
|
|
220
|
-
|
|
221
|
-
const rowMatches = findRowExtraActions(elements).filter(r => r.action.name === actionName)
|
|
222
|
-
if (rowMatches.length === 0) return null
|
|
223
|
-
if (rowMatches.length > 1) {
|
|
224
|
-
console.warn(
|
|
225
|
-
`[pilotiq] Action "${actionName}" registered as extraItemActions on multiple ` +
|
|
226
|
-
`fields. Using the first match — disambiguate by renaming.`,
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
const first = rowMatches[0]!
|
|
230
|
-
// `formSchema` is the entire page tree for v1 — `coerceFormValues`
|
|
231
|
-
// needs the field schema rooted at the form, not just the one row's
|
|
232
|
-
// children. Passing the page tree is over-broad but safe (the function
|
|
233
|
-
// walks until it finds the field). A future polish can narrow to the
|
|
234
|
-
// owning Form once we walk back from the matched field.
|
|
235
|
-
return { action: first.action, rowField: first.field, formSchema: elements }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Plan #5 — handle a partial-resolve POST. The body shape is
|
|
240
|
-
* `{ changed, values }`; `formId` comes from the URL path. Response
|
|
241
|
-
* is `{ ok, form, dirty }` on success or `{ ok: false, error }` for
|
|
242
|
-
* missing form / unknown field.
|
|
243
|
-
*/
|
|
244
|
-
interface FormStateBody {
|
|
245
|
-
changed?: unknown
|
|
246
|
-
values?: unknown
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function handleFormState(
|
|
250
|
-
req: AppRequest,
|
|
251
|
-
res: AppResponse,
|
|
252
|
-
pilotiq: Pilotiq,
|
|
253
|
-
scope: FormStateScope,
|
|
254
|
-
formId: string,
|
|
255
|
-
): Promise<unknown> {
|
|
256
|
-
const body = (await readFormBody(req)) as FormStateBody
|
|
257
|
-
const changed = typeof body.changed === 'string' ? body.changed : ''
|
|
258
|
-
const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
|
|
259
|
-
? body.values as Record<string, unknown>
|
|
260
|
-
: {}
|
|
261
|
-
if (!formId || !changed) {
|
|
262
|
-
res.status(400)
|
|
263
|
-
return res.json({ ok: false, error: 'Missing formId or changed field' })
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const result = await formStateData(pilotiq, scope, { formId, changed, values }, req)
|
|
268
|
-
if (result === null) {
|
|
269
|
-
res.status(404)
|
|
270
|
-
return res.json({ ok: false, error: 'Page not found' })
|
|
271
|
-
}
|
|
272
|
-
if (!result.ok) {
|
|
273
|
-
res.status(result.status)
|
|
274
|
-
return res.json({ ok: false, error: result.error })
|
|
275
|
-
}
|
|
276
|
-
return res.json({ ok: true, form: result.form, dirty: result.dirty })
|
|
277
|
-
} catch (err) {
|
|
278
|
-
const message = err instanceof Error ? err.message : 'Form update failed'
|
|
279
|
-
res.status(500)
|
|
280
|
-
return res.json({ ok: false, error: message })
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
interface FormWizardBody {
|
|
285
|
-
step?: unknown
|
|
286
|
-
values?: unknown
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async function handleFormWizard(
|
|
290
|
-
req: AppRequest,
|
|
291
|
-
res: AppResponse,
|
|
292
|
-
pilotiq: Pilotiq,
|
|
293
|
-
scope: FormStateScope,
|
|
294
|
-
formId: string,
|
|
295
|
-
): Promise<unknown> {
|
|
296
|
-
const body = (await readFormBody(req)) as FormWizardBody
|
|
297
|
-
const stepN = typeof body.step === 'number' ? body.step
|
|
298
|
-
: typeof body.step === 'string' ? Number(body.step)
|
|
299
|
-
: NaN
|
|
300
|
-
const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
|
|
301
|
-
? body.values as Record<string, unknown>
|
|
302
|
-
: {}
|
|
303
|
-
if (!formId || !Number.isFinite(stepN) || stepN < 0) {
|
|
304
|
-
res.status(400)
|
|
305
|
-
return res.json({ ok: false, error: 'Missing formId or invalid step' })
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
const result = await formWizardData(pilotiq, scope, { formId, step: stepN, values }, req)
|
|
310
|
-
if (result === null) {
|
|
311
|
-
res.status(404)
|
|
312
|
-
return res.json({ ok: false, error: 'Page not found' })
|
|
313
|
-
}
|
|
314
|
-
if (!result.ok) {
|
|
315
|
-
res.status(result.status)
|
|
316
|
-
const payload: Record<string, unknown> = { ok: false }
|
|
317
|
-
if (result.error) payload['error'] = result.error
|
|
318
|
-
if (result.errors) payload['errors'] = result.errors
|
|
319
|
-
return res.json(payload)
|
|
320
|
-
}
|
|
321
|
-
return res.json({ ok: true })
|
|
322
|
-
} catch (err) {
|
|
323
|
-
const message = err instanceof Error ? err.message : 'Wizard step validation failed'
|
|
324
|
-
res.status(500)
|
|
325
|
-
return res.json({ ok: false, error: message })
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* Audit row 2026-05-07 cont'd⁸ — `SelectField.createOptionForm()` modal
|
|
331
|
-
* submit. Body carries `{ values }`; `formId` + `fieldName` come from
|
|
332
|
-
* the URL path. Returns `{ ok, option: { value, label } }` on success
|
|
333
|
-
* or `{ ok: false, error }` for missing scope / form / field, 403 for
|
|
334
|
-
* authorize failure, or 422 with `errors` for validation.
|
|
335
|
-
*
|
|
336
|
-
* One handler shared across all four scopes (resource-create /
|
|
337
|
-
* resource-edit / global-edit / custom-page) — caller passes the
|
|
338
|
-
* matching `FormStateScope` so the same `canAccess + canCreate / canEdit`
|
|
339
|
-
* predicates apply to the parent form's policy gate.
|
|
340
|
-
*/
|
|
341
|
-
interface FormCreateOptionBody {
|
|
342
|
-
values?: unknown
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
async function handleFormCreateOption(
|
|
346
|
-
req: AppRequest,
|
|
347
|
-
res: AppResponse,
|
|
348
|
-
pilotiq: Pilotiq,
|
|
349
|
-
scope: FormStateScope,
|
|
350
|
-
formId: string,
|
|
351
|
-
fieldName: string,
|
|
352
|
-
): Promise<unknown> {
|
|
353
|
-
const body = (await readFormBody(req)) as FormCreateOptionBody
|
|
354
|
-
const values = (body.values && typeof body.values === 'object' && !Array.isArray(body.values))
|
|
355
|
-
? body.values as Record<string, unknown>
|
|
356
|
-
: {}
|
|
357
|
-
if (!formId || !fieldName) {
|
|
358
|
-
res.status(400)
|
|
359
|
-
return res.json({ ok: false, error: 'Missing formId or fieldName' })
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
try {
|
|
363
|
-
const result = await formCreateOptionData(pilotiq, scope, { formId, fieldName, values }, req)
|
|
364
|
-
if (result === null) {
|
|
365
|
-
res.status(404)
|
|
366
|
-
return res.json({ ok: false, error: 'Page not found' })
|
|
367
|
-
}
|
|
368
|
-
if (!result.ok) {
|
|
369
|
-
res.status(result.status)
|
|
370
|
-
const payload: Record<string, unknown> = { ok: false }
|
|
371
|
-
if (result.error) payload['error'] = result.error
|
|
372
|
-
if (result.errors) payload['errors'] = result.errors
|
|
373
|
-
return res.json(payload)
|
|
374
|
-
}
|
|
375
|
-
return res.json({ ok: true, option: result.option })
|
|
376
|
-
} catch (err) {
|
|
377
|
-
const message = err instanceof Error ? err.message : 'createOption failed'
|
|
378
|
-
res.status(500)
|
|
379
|
-
return res.json({ ok: false, error: message })
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Async-mention round-trip handler. Body is `{ field, trigger, query }`;
|
|
385
|
-
* `formId` comes from the URL path. Returns `{ ok, items }` on success
|
|
386
|
-
* or `{ ok: false, error }` for missing form / field / trigger.
|
|
387
|
-
*
|
|
388
|
-
* Each scope (resource-create, resource-edit, global-edit, custom-page)
|
|
389
|
-
* registers its own route — the auth gate matches the matching `_form/
|
|
390
|
-
* :formId/state` endpoint so the same `canAccess + canCreate / canEdit`
|
|
391
|
-
* predicates apply.
|
|
392
|
-
*/
|
|
393
|
-
interface FormMentionsBody {
|
|
394
|
-
field?: unknown
|
|
395
|
-
trigger?: unknown
|
|
396
|
-
query?: unknown
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function handleFormMentions(
|
|
400
|
-
req: AppRequest,
|
|
401
|
-
res: AppResponse,
|
|
402
|
-
pilotiq: Pilotiq,
|
|
403
|
-
scope: FormStateScope,
|
|
404
|
-
formId: string,
|
|
405
|
-
): Promise<unknown> {
|
|
406
|
-
const body = (await readFormBody(req)) as FormMentionsBody
|
|
407
|
-
const field = typeof body.field === 'string' ? body.field : ''
|
|
408
|
-
const trigger = typeof body.trigger === 'string' ? body.trigger : ''
|
|
409
|
-
const query = typeof body.query === 'string' ? body.query : ''
|
|
410
|
-
if (!formId || !field || trigger.length !== 1) {
|
|
411
|
-
res.status(400)
|
|
412
|
-
return res.json({ ok: false, error: 'Missing formId / field / trigger' })
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Cap query length — the resolver runs the user's code; the trigger
|
|
416
|
-
// never sends more than a word's worth of characters in practice.
|
|
417
|
-
const cappedQuery = query.length > 200 ? query.slice(0, 200) : query
|
|
418
|
-
|
|
419
|
-
try {
|
|
420
|
-
const result = await mentionResolveData(
|
|
421
|
-
pilotiq,
|
|
422
|
-
scope,
|
|
423
|
-
{ formId, field, trigger, query: cappedQuery },
|
|
424
|
-
req,
|
|
425
|
-
)
|
|
426
|
-
if (result === null) {
|
|
427
|
-
res.status(404)
|
|
428
|
-
return res.json({ ok: false, error: 'Page not found' })
|
|
429
|
-
}
|
|
430
|
-
if (!result.ok) {
|
|
431
|
-
res.status(result.status)
|
|
432
|
-
return res.json({ ok: false, error: result.error })
|
|
433
|
-
}
|
|
434
|
-
return res.json({ ok: true, items: result.items })
|
|
435
|
-
} catch (err) {
|
|
436
|
-
const message = err instanceof Error ? err.message : 'Mention resolve failed'
|
|
437
|
-
res.status(500)
|
|
438
|
-
return res.json({ ok: false, error: message })
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Plan #15 — handle a widget polling POST. Body is `{ filter? }`;
|
|
444
|
-
* `:id` comes from the URL. Returns `{ ok, data, timestamp }` on
|
|
445
|
-
* success or `{ ok: false, error }` on failure. Used by lazy-loading
|
|
446
|
-
* widgets (first fetch on mount) and `poll(seconds)` widgets (interval
|
|
447
|
-
* re-fetch).
|
|
448
|
-
*/
|
|
449
|
-
interface WidgetBody {
|
|
450
|
-
filter?: unknown
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
async function handleWidgetData(
|
|
454
|
-
req: AppRequest,
|
|
455
|
-
res: AppResponse,
|
|
456
|
-
pilotiq: Pilotiq,
|
|
457
|
-
scope: WidgetScope,
|
|
458
|
-
id: string,
|
|
459
|
-
): Promise<unknown> {
|
|
460
|
-
if (!id) {
|
|
461
|
-
res.status(400)
|
|
462
|
-
return res.json({ ok: false, error: 'Missing widget id' })
|
|
463
|
-
}
|
|
464
|
-
const body = (await readFormBody(req)) as WidgetBody
|
|
465
|
-
const filter = typeof body.filter === 'string' ? body.filter : undefined
|
|
466
|
-
|
|
467
|
-
try {
|
|
468
|
-
const result = await widgetData(
|
|
469
|
-
pilotiq,
|
|
470
|
-
scope,
|
|
471
|
-
filter !== undefined ? { id, filter } : { id },
|
|
472
|
-
req,
|
|
473
|
-
)
|
|
474
|
-
if (!result.ok) {
|
|
475
|
-
res.status(result.status)
|
|
476
|
-
return res.json({ ok: false, error: result.error })
|
|
477
|
-
}
|
|
478
|
-
return res.json({ ok: true, data: result.data, timestamp: result.timestamp })
|
|
479
|
-
} catch (err) {
|
|
480
|
-
res.status(500)
|
|
481
|
-
return res.json({ ok: false, error: err instanceof Error ? err.message : 'Widget request failed' })
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Handle a single file upload from a `FileUpload` field. Validates
|
|
487
|
-
* accept / maxSize against the (optional) per-request hints, hands
|
|
488
|
-
* the file off to the configured adapter, returns `{ ok, url }`.
|
|
489
|
-
*
|
|
490
|
-
* Body shape (multipart/form-data):
|
|
491
|
-
* - `file`: the file blob
|
|
492
|
-
* - `directory`: optional sub-directory hint
|
|
493
|
-
* - `accept`: optional comma-separated MIME list to enforce
|
|
494
|
-
* - `maxSize`: optional byte cap
|
|
495
|
-
* - `fieldName`: optional tag forwarded to the adapter for routing
|
|
496
|
-
*/
|
|
497
|
-
async function handleUploadRequest(
|
|
498
|
-
req: AppRequest,
|
|
499
|
-
res: AppResponse,
|
|
500
|
-
pilotiq: Pilotiq,
|
|
501
|
-
): Promise<unknown> {
|
|
502
|
-
const cfg = pilotiq.getConfig()
|
|
503
|
-
if (!cfg.uploads) {
|
|
504
|
-
res.status(500)
|
|
505
|
-
return res.json({ ok: false, error: 'No upload adapter configured' })
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Auth: panel-wide `guard` and per-request `user`. We don't enforce
|
|
509
|
-
// per-resource canEdit here because the field doesn't know which
|
|
510
|
-
// resource it belongs to — apps that need it should hook into
|
|
511
|
-
// their adapter's `put()` and consult their own auth there.
|
|
512
|
-
if (cfg.guard && !await cfg.guard(req)) {
|
|
513
|
-
res.status(401)
|
|
514
|
-
return res.json({ ok: false, error: 'Unauthorized' })
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Parse multipart body. Hono's parseBody returns `Record<string, File | string>`.
|
|
518
|
-
const raw = req.raw as { req?: { parseBody?: (opts?: { all?: boolean }) => Promise<Record<string, unknown>> } } | undefined
|
|
519
|
-
if (!raw?.req?.parseBody) {
|
|
520
|
-
res.status(500)
|
|
521
|
-
return res.json({ ok: false, error: 'Multipart parsing unavailable' })
|
|
522
|
-
}
|
|
523
|
-
let body: Record<string, unknown>
|
|
524
|
-
try {
|
|
525
|
-
body = await raw.req.parseBody()
|
|
526
|
-
} catch (err) {
|
|
527
|
-
res.status(400)
|
|
528
|
-
return res.json({ ok: false, error: err instanceof Error ? err.message : 'Bad request' })
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const file = body['file']
|
|
532
|
-
if (!file || !(file instanceof File)) {
|
|
533
|
-
res.status(422)
|
|
534
|
-
return res.json({ ok: false, error: 'No file provided' })
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const directory = typeof body['directory'] === 'string' ? body['directory'] : undefined
|
|
538
|
-
const fieldName = typeof body['fieldName'] === 'string' ? body['fieldName'] : ''
|
|
539
|
-
|
|
540
|
-
// Server-side validation. Both accept and maxSize are advisory hints
|
|
541
|
-
// shipped by the field meta, so we re-check here so a tampered client
|
|
542
|
-
// can't bypass the limits.
|
|
543
|
-
const acceptStr = typeof body['accept'] === 'string' ? body['accept'] : ''
|
|
544
|
-
if (acceptStr) {
|
|
545
|
-
const accept = acceptStr.split(',').map(s => s.trim()).filter(Boolean)
|
|
546
|
-
if (accept.length > 0 && !accept.includes(file.type)) {
|
|
547
|
-
res.status(422)
|
|
548
|
-
return res.json({ ok: false, error: `File type "${file.type}" not allowed` })
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
const maxSizeStr = typeof body['maxSize'] === 'string' ? body['maxSize'] : ''
|
|
552
|
-
if (maxSizeStr) {
|
|
553
|
-
const maxSize = Number(maxSizeStr)
|
|
554
|
-
if (Number.isFinite(maxSize) && file.size > maxSize) {
|
|
555
|
-
res.status(422)
|
|
556
|
-
return res.json({ ok: false, error: `File exceeds ${maxSize} bytes` })
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Server-side resize via @rudderjs/image (optional peer dep). Variable-
|
|
561
|
-
// string `import(name)` keeps Vite's static import-analysis from trying
|
|
562
|
-
// to pre-resolve the module on host apps that don't have @rudderjs/image
|
|
563
|
-
// installed — same pattern as `notifications/database.ts` for `@rudderjs/orm`.
|
|
564
|
-
const resizeWidthStr = typeof body['resize_width'] === 'string' ? body['resize_width'] : ''
|
|
565
|
-
const resizeHeightStr = typeof body['resize_height'] === 'string' ? body['resize_height'] : ''
|
|
566
|
-
let uploadFile: File = file
|
|
567
|
-
if (resizeWidthStr && resizeHeightStr) {
|
|
568
|
-
const w = Number(resizeWidthStr)
|
|
569
|
-
const h = Number(resizeHeightStr)
|
|
570
|
-
if (Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0) {
|
|
571
|
-
try {
|
|
572
|
-
const imageModuleName = '@rudderjs/image'
|
|
573
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
574
|
-
const pkg = await import(/* @vite-ignore */ imageModuleName) as { image: (input: unknown) => { resize(w: number, h: number): { format(f: string): { toBuffer(): Promise<Buffer> } } } }
|
|
575
|
-
const buf = await pkg.image(file).resize(w, h).format('webp').toBuffer()
|
|
576
|
-
const baseName = file.name.replace(/\.[^.]+$/, '')
|
|
577
|
-
uploadFile = new File([buf.buffer as ArrayBuffer], `${baseName}.webp`, { type: 'image/webp' })
|
|
578
|
-
} catch {
|
|
579
|
-
// @rudderjs/image not installed or resize failed — fall through with original file
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
try {
|
|
585
|
-
const result = await cfg.uploads.adapter.put({
|
|
586
|
-
file: uploadFile,
|
|
587
|
-
...(directory ? { directory } : {}),
|
|
588
|
-
fieldName,
|
|
589
|
-
})
|
|
590
|
-
return res.json({ ok: true, url: result.url, ...(result.meta ? { meta: result.meta } : {}) })
|
|
591
|
-
} catch (err) {
|
|
592
|
-
res.status(500)
|
|
593
|
-
return res.json({ ok: false, error: err instanceof Error ? err.message : 'Upload failed' })
|
|
594
|
-
}
|
|
595
|
-
}
|
|
10
|
+
// `routes.ts` is split into a directory of focused modules under
|
|
11
|
+
// `./routes/`. This file is the orchestrator — boot-time validation
|
|
12
|
+
// loops + the per-Resource / per-Global / per-Page registration
|
|
13
|
+
// dispatchers. See `docs/plans/routes-split.md` for the per-phase map.
|
|
14
|
+
import { registerPanelRoutes } from './routes/panel.js'
|
|
15
|
+
import { registerResourceRoutes } from './routes/resources.js'
|
|
16
|
+
import { registerGlobalRoutes } from './routes/globals.js'
|
|
17
|
+
import { registerCustomPageRoutes } from './routes/pages.js'
|
|
18
|
+
import { registerThemeRoutes } from './routes/theme.js'
|
|
596
19
|
|
|
597
20
|
export function registerPilotiqRoutes(
|
|
598
21
|
router: Router,
|
|
@@ -750,2621 +173,86 @@ export function registerPilotiqRoutes(
|
|
|
750
173
|
}
|
|
751
174
|
}
|
|
752
175
|
|
|
753
|
-
// Reorderable rows — fail fast at boot when a
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
// Plan #13's restore/forceDelete guards.
|
|
759
|
-
// so the route loop below can decide whether to
|
|
176
|
+
// Reorderable rows + editable cell columns — fail fast at boot when a
|
|
177
|
+
// Resource declares either capability but its bound model can't
|
|
178
|
+
// persist. We invoke `R.table(Table.make())` once per resource (same
|
|
179
|
+
// call shape `defaultPages` uses at request time) and inspect both
|
|
180
|
+
// markers in a single pass. The model.reorder / model.update checks
|
|
181
|
+
// are symmetric with Plan #13's restore/forceDelete guards. Results
|
|
182
|
+
// cached per-resource so the route loop below can decide whether to
|
|
183
|
+
// mount `_reorder` / `_cell`.
|
|
760
184
|
const reorderEnabled = new Map<string, string>() // slug → column
|
|
761
|
-
for (const R of cfg.resources) {
|
|
762
|
-
let probeColumn: string | undefined
|
|
763
|
-
try { probeColumn = R.table(Table.make()).getReorderableColumn() }
|
|
764
|
-
catch { continue } // user-side throw — not a reorder concern
|
|
765
|
-
if (probeColumn === undefined) continue
|
|
766
|
-
if (!R.model || typeof R.model.reorder !== 'function') {
|
|
767
|
-
throw new Error(
|
|
768
|
-
`[Pilotiq] ${R.name}.table() calls reorderable("${probeColumn}") but the bound model has no reorder(ids) method. ` +
|
|
769
|
-
`Implement \`async reorder(ids)\` on the rudder Model (or remove the .reorderable() call).`,
|
|
770
|
-
)
|
|
771
|
-
}
|
|
772
|
-
reorderEnabled.set(R.getSlug(), probeColumn)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Editable cell columns — fail fast at boot when a Resource declares
|
|
776
|
-
// at least one TextInput/Toggle/SelectColumn but the bound model
|
|
777
|
-
// can't persist a single-column update. Mirrors the reorder guard
|
|
778
|
-
// above. Result is cached per-resource so the route loop below can
|
|
779
|
-
// decide whether to mount `_cell`.
|
|
780
185
|
const editableEnabled = new Set<string>()
|
|
781
186
|
for (const R of cfg.resources) {
|
|
782
|
-
let
|
|
783
|
-
try {
|
|
784
|
-
|
|
785
|
-
.some(c => c instanceof Column && c.isEditable())
|
|
786
|
-
} catch { continue }
|
|
787
|
-
if (!hasEditable) continue
|
|
788
|
-
if (!R.model || typeof R.model.update !== 'function') {
|
|
789
|
-
throw new Error(
|
|
790
|
-
`[Pilotiq] ${R.name}.table() declares an editable cell column ` +
|
|
791
|
-
`(TextInputColumn / ToggleColumn / SelectColumn) but the bound ` +
|
|
792
|
-
`model has no update(id, data) method. Set Resource.model = M ` +
|
|
793
|
-
`(rudder ORM convention) or drop the editable column.`,
|
|
794
|
-
)
|
|
795
|
-
}
|
|
796
|
-
editableEnabled.add(R.getSlug())
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ── Dashboard (1-segment) ─────────────────────────────
|
|
800
|
-
router.get(base, async (req, res) => {
|
|
801
|
-
// Plan #15 — when `panel.dashboard(P)` is set, gate the dashboard
|
|
802
|
-
// route through the page's `canAccess` predicate. Same posture as
|
|
803
|
-
// custom pages — fail-closed on throw.
|
|
804
|
-
if (cfg.dashboardPage) {
|
|
805
|
-
const user = await pilotiq.resolveUser(req)
|
|
806
|
-
if (!await policyAccess(cfg.dashboardPage!, user)) {
|
|
807
|
-
return forbidden(res, wantsJson(req))
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return view('pilotiq.dashboard', await dashboardData(pilotiq, req))
|
|
811
|
-
})
|
|
812
|
-
|
|
813
|
-
// ── File uploads (FileUpload field POST target) ───────
|
|
814
|
-
router.post(`${base}/_uploads`, async (req, res) => {
|
|
815
|
-
return handleUploadRequest(req, res, pilotiq)
|
|
816
|
-
})
|
|
817
|
-
|
|
818
|
-
// ── Plan #15 dashboard widget polling ─────────────────
|
|
819
|
-
// POST ${base}/_widget/:id — re-resolves the dashboard page schema,
|
|
820
|
-
// finds widget by id, runs `getServerData(ctx)`. Body: `{ filter? }`.
|
|
821
|
-
// Mounted unconditionally — widgetData() returns 404 when no
|
|
822
|
-
// dashboard page is registered, so this stays cheap when unused.
|
|
823
|
-
router.post(`${base}/_widget/:id`, async (req, res) => {
|
|
824
|
-
if (cfg.dashboardPage) {
|
|
825
|
-
const user = await pilotiq.resolveUser(req)
|
|
826
|
-
if (!await policyAccess(cfg.dashboardPage!, user)) return forbidden(res, true)
|
|
827
|
-
}
|
|
828
|
-
return handleWidgetData(req, res, pilotiq, { kind: 'panel' }, req.params['id']!)
|
|
829
|
-
})
|
|
830
|
-
|
|
831
|
-
// ── Plan #12 global search ────────────────────────────
|
|
832
|
-
// GET ${base}/_search?q=…&limit=… → { ok, results }
|
|
833
|
-
// No 403 on unrecognised users — `searchAllResources` filters per
|
|
834
|
-
// resource. The Pilotiq.guard() layer above is the panel-level gate.
|
|
835
|
-
router.get(`${base}/_search`, async (req, res) => {
|
|
836
|
-
const query = req.query as Record<string, unknown> | undefined
|
|
837
|
-
const rawQ = query?.['q']
|
|
838
|
-
const q = typeof rawQ === 'string' ? rawQ.slice(0, 200) : ''
|
|
839
|
-
const data = await searchData(pilotiq, q, req)
|
|
840
|
-
return res.json(data)
|
|
841
|
-
})
|
|
842
|
-
|
|
843
|
-
// ── Database notifications (bell-icon dropdown) ───────
|
|
844
|
-
// Only mounted when `Pilotiq.databaseNotifications()` was called.
|
|
845
|
-
// Every route 401s when no user resolves so a non-authenticated
|
|
846
|
-
// request never sees another user's inbox. The `notifiable_type`
|
|
847
|
-
// value is configurable but defaults to `'users'` to match
|
|
848
|
-
// `@rudderjs/notification`'s `DatabaseChannel` writes.
|
|
849
|
-
if (cfg.databaseNotifications?.enabled) {
|
|
850
|
-
const dn = cfg.databaseNotifications
|
|
851
|
-
const notifiableType = dn.notifiableType ?? 'users'
|
|
852
|
-
const pageSize = dn.pageSize ?? 25
|
|
853
|
-
|
|
854
|
-
/** Resolve `{ id }` from the panel's user resolver. Returns null
|
|
855
|
-
* when no user / unknown id — every route then 401s. The user
|
|
856
|
-
* object is opaque to pilotiq; we duck-type `.id`. */
|
|
857
|
-
const resolveUserId = async (req: AppRequest): Promise<string | null> => {
|
|
858
|
-
const user = await pilotiq.resolveUser(req)
|
|
859
|
-
if (!user || typeof user !== 'object') return null
|
|
860
|
-
const id = (user as { id?: unknown }).id
|
|
861
|
-
if (id === undefined || id === null) return null
|
|
862
|
-
return String(id)
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// GET ${base}/_notifications → { notifications, unreadCount }
|
|
866
|
-
router.get(`${base}/_notifications`, async (req, res) => {
|
|
867
|
-
const id = await resolveUserId(req)
|
|
868
|
-
if (id === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
|
|
869
|
-
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
870
|
-
const unreadOnly = url.searchParams.get('unread') === 'true'
|
|
871
|
-
const limitRaw = Number(url.searchParams.get('limit') ?? pageSize)
|
|
872
|
-
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : pageSize
|
|
873
|
-
const data = await listDatabaseNotifications({
|
|
874
|
-
notifiableType,
|
|
875
|
-
notifiableId: id,
|
|
876
|
-
limit,
|
|
877
|
-
unreadOnly,
|
|
878
|
-
})
|
|
879
|
-
return res.json({ ok: true, ...data })
|
|
880
|
-
})
|
|
881
|
-
|
|
882
|
-
// POST ${base}/_notifications/:id/read
|
|
883
|
-
router.post(`${base}/_notifications/:id/read`, async (req, res) => {
|
|
884
|
-
const userId = await resolveUserId(req)
|
|
885
|
-
if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
|
|
886
|
-
const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
|
|
887
|
-
const updated = await markDatabaseNotificationAsRead(rowId, {
|
|
888
|
-
notifiableType,
|
|
889
|
-
notifiableId: userId,
|
|
890
|
-
})
|
|
891
|
-
return res.json({ ok: updated })
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
// POST ${base}/_notifications/:id/unread
|
|
895
|
-
router.post(`${base}/_notifications/:id/unread`, async (req, res) => {
|
|
896
|
-
const userId = await resolveUserId(req)
|
|
897
|
-
if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
|
|
898
|
-
const rowId = (req.params as Record<string, string | undefined>)['id'] ?? ''
|
|
899
|
-
const updated = await markDatabaseNotificationAsUnread(rowId, {
|
|
900
|
-
notifiableType,
|
|
901
|
-
notifiableId: userId,
|
|
902
|
-
})
|
|
903
|
-
return res.json({ ok: updated })
|
|
904
|
-
})
|
|
905
|
-
|
|
906
|
-
// POST ${base}/_notifications/read-all
|
|
907
|
-
router.post(`${base}/_notifications/read-all`, async (req, res) => {
|
|
908
|
-
const userId = await resolveUserId(req)
|
|
909
|
-
if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
|
|
910
|
-
const count = await markAllDatabaseNotificationsAsRead({
|
|
911
|
-
notifiableType,
|
|
912
|
-
notifiableId: userId,
|
|
913
|
-
})
|
|
914
|
-
return res.json({ ok: true, count })
|
|
915
|
-
})
|
|
916
|
-
|
|
917
|
-
// POST ${base}/_notifications/:id/_action/:actionName
|
|
918
|
-
//
|
|
919
|
-
// Notification action dispatch — looks up the stored action on the
|
|
920
|
-
// row, resolves the named handler against the panel's
|
|
921
|
-
// `notificationHandlers` registry, and runs it with the row's
|
|
922
|
-
// stored payload. Optionally flips `read_at` server-side when the
|
|
923
|
-
// action carried `markAsRead: true`.
|
|
924
|
-
//
|
|
925
|
-
// Defends in depth: 404s on missing row / wrong owner / action
|
|
926
|
-
// missing / non-string handler / unknown registry name. Body is
|
|
927
|
-
// ignored — payload reads exclusively from the stored row, so a
|
|
928
|
-
// tampered client can't inject extra payload keys.
|
|
929
|
-
router.post(`${base}/_notifications/:id/_action/:actionName`, async (req, res) => {
|
|
930
|
-
const user = await pilotiq.resolveUser(req)
|
|
931
|
-
const userId = user && typeof user === 'object'
|
|
932
|
-
? ((user as { id?: unknown }).id !== undefined && (user as { id?: unknown }).id !== null
|
|
933
|
-
? String((user as { id: unknown }).id) : null)
|
|
934
|
-
: null
|
|
935
|
-
if (userId === null) { res.status(401); return res.json({ ok: false, error: 'Not authenticated' }) }
|
|
936
|
-
|
|
937
|
-
const params = (req.params as Record<string, string | undefined>)
|
|
938
|
-
const result = await dispatchNotificationAction(pilotiq, {
|
|
939
|
-
notificationId: params['id'] ?? '',
|
|
940
|
-
actionName: params['actionName'] ?? '',
|
|
941
|
-
notifiableType,
|
|
942
|
-
notifiableId: userId,
|
|
943
|
-
user,
|
|
944
|
-
request: req,
|
|
945
|
-
})
|
|
946
|
-
if (!result.ok) {
|
|
947
|
-
res.status(result.status)
|
|
948
|
-
return res.json({ ok: false, error: result.error })
|
|
949
|
-
}
|
|
950
|
-
return res.json(result)
|
|
951
|
-
})
|
|
952
|
-
|
|
953
|
-
// Phase 2 — register the broadcast auth callback for private
|
|
954
|
-
// `pilotiq-notifications.<userId>` channels. Soft-fails when
|
|
955
|
-
// `@rudderjs/broadcast` isn't installed; apps that haven't enabled
|
|
956
|
-
// broadcast on the toggle stay quiet either way.
|
|
957
|
-
void registerBroadcastAuth(pilotiq)
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// ── Resource routes ───────────────────────────────────
|
|
961
|
-
for (const R of cfg.resources) {
|
|
962
|
-
const slug = R.getSlug()
|
|
963
|
-
const resourceBase = resourceBasePath(base, R)
|
|
964
|
-
const pages = R.resolvePages()
|
|
965
|
-
|
|
966
|
-
// Index — GET ${resourceBase}
|
|
967
|
-
if (pages.index) {
|
|
968
|
-
const PageClass = pages.index
|
|
969
|
-
const indexUrl = resourceBase
|
|
970
|
-
router.get(indexUrl, async (req, res) => {
|
|
971
|
-
const user = await pilotiq.resolveUser(req)
|
|
972
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
973
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, wantsJson(req))
|
|
974
|
-
|
|
975
|
-
if (R.persistFiltersInSession) {
|
|
976
|
-
const query = (req.query as Record<string, unknown> | undefined) ?? {}
|
|
977
|
-
const sessionSlug = resourceBase.slice(base.length + 1)
|
|
978
|
-
if (Object.keys(query).length === 0) {
|
|
979
|
-
const restoreTab = readPersistedLastTab(req, base, sessionSlug) ?? ''
|
|
980
|
-
const stored = readPersistedListQuery(req, listFiltersKey(base, sessionSlug, restoreTab))
|
|
981
|
-
if (stored) {
|
|
982
|
-
const qs = encodePersistedQuery(stored, restoreTab)
|
|
983
|
-
if (qs !== '') return res.redirect(`${indexUrl}?${qs}`, 302)
|
|
984
|
-
}
|
|
985
|
-
} else {
|
|
986
|
-
const tab = typeof query['tab'] === 'string' ? query['tab'] : ''
|
|
987
|
-
writePersistedListQuery(req, listFiltersKey(base, sessionSlug, tab), query)
|
|
988
|
-
writePersistedLastTab(req, base, sessionSlug, tab)
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
const data = await resourceIndexData(pilotiq, slug, req.query, req)
|
|
993
|
-
return view('pilotiq.slug', data ?? {})
|
|
994
|
-
})
|
|
995
|
-
|
|
996
|
-
router.post(`${indexUrl}/_widget/:id`, async (req, res) => {
|
|
997
|
-
const user = await pilotiq.resolveUser(req)
|
|
998
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
999
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
|
|
1000
|
-
return handleWidgetData(req, res, pilotiq, { kind: 'resource', slug }, req.params['id']!)
|
|
1001
|
-
})
|
|
1002
|
-
|
|
1003
|
-
if (R.deferLoading) {
|
|
1004
|
-
router.get(`${indexUrl}/_table`, async (req, res) => {
|
|
1005
|
-
const user = await pilotiq.resolveUser(req)
|
|
1006
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1007
|
-
if (!await checkPolicy(() => R.canViewAny(user))) return forbidden(res, true)
|
|
1008
|
-
const data = await resourceTableData(pilotiq, slug, req.query as Record<string, string>, req)
|
|
1009
|
-
if (!data) { res.status(404); return res.json({ ok: false, error: 'Resource not found' }) }
|
|
1010
|
-
return res.json({ ok: true, ...data })
|
|
1011
|
-
})
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// Action dispatch — POST ${resourceBase}/_action/:actionName
|
|
1015
|
-
router.post(`${indexUrl}/_action/:actionName`, async (req, res) => {
|
|
1016
|
-
const user = await pilotiq.resolveUser(req)
|
|
1017
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1018
|
-
|
|
1019
|
-
const actionName = req.params['actionName']!
|
|
1020
|
-
const json = wantsJson(req)
|
|
1021
|
-
const body = await readFormBody(req)
|
|
1022
|
-
const input = parseActionBody(body)
|
|
1023
|
-
|
|
1024
|
-
const ctx: SchemaContext = { mode: 'table', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
1025
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1026
|
-
tagActionDispatch(elements, indexUrl)
|
|
1027
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
1028
|
-
if (!target) {
|
|
1029
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
1030
|
-
res.status(404)
|
|
1031
|
-
return res.send(`Action "${actionName}" not found on ${R.label}`)
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
const resolveRecord: ResolveRecord | undefined = R.model
|
|
1035
|
-
? (id: string) => findRecord(R, id, { user })
|
|
1036
|
-
: undefined
|
|
1037
|
-
|
|
1038
|
-
const result = await dispatchAction(target.action, {
|
|
1039
|
-
...input,
|
|
1040
|
-
request: req,
|
|
1041
|
-
user,
|
|
1042
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
1043
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
1044
|
-
}, resolveRecord)
|
|
1045
|
-
if (!result.ok) {
|
|
1046
|
-
if (json) {
|
|
1047
|
-
res.status(result.errors ? 422 : 500)
|
|
1048
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
1049
|
-
}
|
|
1050
|
-
res.status(500)
|
|
1051
|
-
return res.send(result.error)
|
|
1052
|
-
}
|
|
1053
|
-
// Download envelope wins over redirect — `Action.export` and friends
|
|
1054
|
-
// return the file body inline. Notifications dropped on this branch
|
|
1055
|
-
// because the binary response has no JSON envelope to carry them;
|
|
1056
|
-
// the file itself is the success signal.
|
|
1057
|
-
if (result.download) return sendDownload(res, result.download)
|
|
1058
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? indexUrl
|
|
1059
|
-
if (json) {
|
|
1060
|
-
return res.json({
|
|
1061
|
-
ok: true,
|
|
1062
|
-
redirect,
|
|
1063
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
1064
|
-
})
|
|
1065
|
-
}
|
|
1066
|
-
flashNotifications(req, result.notifications)
|
|
1067
|
-
return res.redirect(redirect, 303)
|
|
1068
|
-
})
|
|
1069
|
-
|
|
1070
|
-
// Reorderable rows — POST ${resourceBase}/_reorder { ids: [] }
|
|
1071
|
-
// Only mounted when `Resource.table()` opts in (boot-time probe
|
|
1072
|
-
// populates `reorderEnabled`).
|
|
1073
|
-
if (reorderEnabled.has(slug)) {
|
|
1074
|
-
router.post(`${indexUrl}/_reorder`, async (req, res) => {
|
|
1075
|
-
const user = await pilotiq.resolveUser(req)
|
|
1076
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1077
|
-
// List-level edit gate. The drop affects many rows at once;
|
|
1078
|
-
// there's no single record to authorize against, so we pass
|
|
1079
|
-
// `undefined` and let user-supplied `canEdit` overrides branch
|
|
1080
|
-
// on `record === undefined` if they want row-level granularity.
|
|
1081
|
-
if (!await checkPolicy(() => R.canEdit(user, undefined))) return forbidden(res, true)
|
|
1082
|
-
|
|
1083
|
-
const body = await readFormBody(req)
|
|
1084
|
-
const raw = (body as { ids?: unknown }).ids
|
|
1085
|
-
if (!Array.isArray(raw) || raw.length === 0) {
|
|
1086
|
-
res.status(400)
|
|
1087
|
-
return res.json({ ok: false, error: 'Missing or empty ids array' })
|
|
1088
|
-
}
|
|
1089
|
-
const ids = raw.filter((id): id is string | number =>
|
|
1090
|
-
typeof id === 'string' || typeof id === 'number',
|
|
1091
|
-
)
|
|
1092
|
-
if (ids.length !== raw.length) {
|
|
1093
|
-
res.status(400)
|
|
1094
|
-
return res.json({ ok: false, error: 'ids must contain only strings or numbers' })
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
try {
|
|
1098
|
-
// Boot already verified `R.model?.reorder` exists; the `!`
|
|
1099
|
-
// assertions are safe.
|
|
1100
|
-
await R.model!.reorder!(ids)
|
|
1101
|
-
return res.json({ ok: true })
|
|
1102
|
-
} catch (err) {
|
|
1103
|
-
res.status(422)
|
|
1104
|
-
return res.json({
|
|
1105
|
-
ok: false,
|
|
1106
|
-
error: err instanceof Error ? err.message : 'Reorder failed',
|
|
1107
|
-
})
|
|
1108
|
-
}
|
|
1109
|
-
})
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// Editable cell columns — POST ${resourceBase}/:id/_cell/:column
|
|
1113
|
-
// { value: <coerced> }. Only mounted when the resource declares at
|
|
1114
|
-
// least one editable column (boot-time probe populates
|
|
1115
|
-
// `editableEnabled`).
|
|
1116
|
-
if (editableEnabled.has(slug)) {
|
|
1117
|
-
router.post(`${indexUrl}/:id/_cell/:column`, async (req, res) => {
|
|
1118
|
-
const user = await pilotiq.resolveUser(req)
|
|
1119
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1120
|
-
|
|
1121
|
-
const id = req.params['id']!
|
|
1122
|
-
const colName = req.params['column']!
|
|
1123
|
-
|
|
1124
|
-
// Locate the column on the table. We re-derive `Table.make()`
|
|
1125
|
-
// here (same probe shape used by the boot guard + reorder route)
|
|
1126
|
-
// so the column instance carries its validators / discriminator.
|
|
1127
|
-
const probe = R.table(Table.make())
|
|
1128
|
-
const col = (probe.getChildren() ?? [])
|
|
1129
|
-
.find((c): c is Column => c instanceof Column && c.name === colName)
|
|
1130
|
-
if (!col) {
|
|
1131
|
-
res.status(400)
|
|
1132
|
-
return res.json({ ok: false, error: `Unknown column "${colName}"` })
|
|
1133
|
-
}
|
|
1134
|
-
if (!col.isEditable()) {
|
|
1135
|
-
res.status(400)
|
|
1136
|
-
return res.json({ ok: false, error: `Column "${colName}" is not editable` })
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
// Boot already verified `R.model?.update`; the `!` is safe.
|
|
1140
|
-
const record = await findRecord(R, id, { user })
|
|
1141
|
-
if (record === null || record === undefined) {
|
|
1142
|
-
res.status(404)
|
|
1143
|
-
return res.json({ ok: false, error: 'Record not found' })
|
|
1144
|
-
}
|
|
1145
|
-
if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, true)
|
|
1146
|
-
|
|
1147
|
-
const body = await readFormBody(req)
|
|
1148
|
-
const raw = (body as { value?: unknown }).value
|
|
1149
|
-
|
|
1150
|
-
let value: unknown
|
|
1151
|
-
try { value = coerceCellValue(col, raw) }
|
|
1152
|
-
catch (err) {
|
|
1153
|
-
const message = err instanceof CellCoerceError ? err.message
|
|
1154
|
-
: err instanceof Error ? err.message
|
|
1155
|
-
: 'Invalid value'
|
|
1156
|
-
res.status(422)
|
|
1157
|
-
return res.json({ ok: false, errors: { value: [message] } })
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
const errors = await col.runValidators(value, { record })
|
|
1161
|
-
if (errors.length > 0) {
|
|
1162
|
-
res.status(422)
|
|
1163
|
-
return res.json({ ok: false, errors: { value: errors } })
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// beforeStateUpdated — runs after validators pass, before the
|
|
1167
|
-
// DB write. Throwing halts with 422 under `_cell`.
|
|
1168
|
-
const beforeHook = col.getBeforeStateUpdated()
|
|
1169
|
-
if (beforeHook) {
|
|
1170
|
-
try { await beforeHook(value, { record: record as Record<string, unknown>, user }) }
|
|
1171
|
-
catch (err) {
|
|
1172
|
-
res.status(422)
|
|
1173
|
-
return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
try {
|
|
1178
|
-
await R.model!.update(id, { [col.name]: value })
|
|
1179
|
-
} catch (err) {
|
|
1180
|
-
res.status(422)
|
|
1181
|
-
return res.json({
|
|
1182
|
-
ok: false,
|
|
1183
|
-
error: err instanceof Error ? err.message : 'Update failed',
|
|
1184
|
-
})
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
// afterStateUpdated — runs only on a confirmed write. Throwing
|
|
1188
|
-
// surfaces the error to the user; the DB row is already
|
|
1189
|
-
// updated (the hook is for follow-up effects, not rollback).
|
|
1190
|
-
const afterHook = col.getAfterStateUpdated()
|
|
1191
|
-
if (afterHook) {
|
|
1192
|
-
try { await afterHook(value, { record: record as Record<string, unknown>, user }) }
|
|
1193
|
-
catch (err) {
|
|
1194
|
-
res.status(422)
|
|
1195
|
-
return res.json({ ok: false, errors: { _cell: [cellHookErrorMessage(err)] } })
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
return res.json({ ok: true, value, notifications: [] })
|
|
1200
|
-
})
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// Plan #5 — partial-resolve endpoint for create-mode forms.
|
|
1205
|
-
// POST ${resourceBase}/_form/:formId/state
|
|
1206
|
-
if (pages.create) {
|
|
1207
|
-
router.post(`${resourceBase}/_form/:formId/state`, async (req, res) => {
|
|
1208
|
-
const user = await pilotiq.resolveUser(req)
|
|
1209
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1210
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
1211
|
-
const formId = req.params['formId']!
|
|
1212
|
-
return handleFormState(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
1213
|
-
})
|
|
1214
|
-
|
|
1215
|
-
// Plan #8 — wizard step-validate endpoint for create-mode forms.
|
|
1216
|
-
router.post(`${resourceBase}/_form/:formId/wizard`, async (req, res) => {
|
|
1217
|
-
const user = await pilotiq.resolveUser(req)
|
|
1218
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1219
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
1220
|
-
const formId = req.params['formId']!
|
|
1221
|
-
return handleFormWizard(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
1222
|
-
})
|
|
1223
|
-
|
|
1224
|
-
// Async-mention endpoint for create-mode forms.
|
|
1225
|
-
router.post(`${resourceBase}/_form/:formId/mentions`, async (req, res) => {
|
|
1226
|
-
const user = await pilotiq.resolveUser(req)
|
|
1227
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1228
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
1229
|
-
const formId = req.params['formId']!
|
|
1230
|
-
return handleFormMentions(req, res, pilotiq, { kind: 'resource-create', slug }, formId)
|
|
1231
|
-
})
|
|
1232
|
-
|
|
1233
|
-
// SelectField inline-create modal endpoint for create-mode forms.
|
|
1234
|
-
router.post(`${resourceBase}/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
1235
|
-
const user = await pilotiq.resolveUser(req)
|
|
1236
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1237
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, true)
|
|
1238
|
-
const formId = req.params['formId']!
|
|
1239
|
-
const fieldName = req.params['fieldName']!
|
|
1240
|
-
return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-create', slug }, formId, fieldName)
|
|
1241
|
-
})
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// Plan #5 — partial-resolve endpoint for edit-mode forms.
|
|
1245
|
-
// POST ${resourceBase}/:id/_form/:formId/state
|
|
1246
|
-
if (pages.edit) {
|
|
1247
|
-
router.post(`${resourceBase}/:id/_form/:formId/state`, async (req, res) => {
|
|
1248
|
-
const recordId = req.params['id']!
|
|
1249
|
-
const formId = req.params['formId']!
|
|
1250
|
-
const user = await pilotiq.resolveUser(req)
|
|
1251
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1252
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1253
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
|
|
1254
|
-
return handleFormState(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
|
|
1255
|
-
})
|
|
1256
|
-
|
|
1257
|
-
// Plan #8 — wizard step-validate endpoint for edit-mode forms.
|
|
1258
|
-
router.post(`${resourceBase}/:id/_form/:formId/wizard`, async (req, res) => {
|
|
1259
|
-
const recordId = req.params['id']!
|
|
1260
|
-
const formId = req.params['formId']!
|
|
1261
|
-
const user = await pilotiq.resolveUser(req)
|
|
1262
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1263
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1264
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
|
|
1265
|
-
return handleFormWizard(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
|
|
1266
|
-
})
|
|
1267
|
-
|
|
1268
|
-
// Async-mention endpoint for edit-mode forms.
|
|
1269
|
-
router.post(`${resourceBase}/:id/_form/:formId/mentions`, async (req, res) => {
|
|
1270
|
-
const recordId = req.params['id']!
|
|
1271
|
-
const formId = req.params['formId']!
|
|
1272
|
-
const user = await pilotiq.resolveUser(req)
|
|
1273
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1274
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1275
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
|
|
1276
|
-
return handleFormMentions(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId)
|
|
1277
|
-
})
|
|
1278
|
-
|
|
1279
|
-
// SelectField inline-create modal endpoint for edit-mode forms.
|
|
1280
|
-
router.post(`${resourceBase}/:id/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
1281
|
-
const recordId = req.params['id']!
|
|
1282
|
-
const formId = req.params['formId']!
|
|
1283
|
-
const fieldName = req.params['fieldName']!
|
|
1284
|
-
const user = await pilotiq.resolveUser(req)
|
|
1285
|
-
if (!await policyAccess(R, user)) return forbidden(res, true)
|
|
1286
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1287
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, true)
|
|
1288
|
-
return handleFormCreateOption(req, res, pilotiq, { kind: 'resource-edit', slug, recordId }, formId, fieldName)
|
|
1289
|
-
})
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Create — GET ${resourceBase}/create
|
|
1293
|
-
if (pages.create) {
|
|
1294
|
-
const PageClass = pages.create
|
|
1295
|
-
const createUrl = `${resourceBase}/create`
|
|
1296
|
-
|
|
1297
|
-
router.get(createUrl, async (req, res) => {
|
|
1298
|
-
const user = await pilotiq.resolveUser(req)
|
|
1299
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1300
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
1301
|
-
const data = await resourceCreateData(pilotiq, slug, undefined, req)
|
|
1302
|
-
return view('pilotiq.resource-create', data ?? {})
|
|
1303
|
-
})
|
|
1304
|
-
|
|
1305
|
-
// Create — POST ${resourceBase}/create
|
|
1306
|
-
router.post(createUrl, async (req, res) => {
|
|
1307
|
-
const user = await pilotiq.resolveUser(req)
|
|
1308
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1309
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
1310
|
-
|
|
1311
|
-
const body = await readFormBody(req)
|
|
1312
|
-
const { values, formId, continueCreate } = splitMeta(body)
|
|
1313
|
-
const json = wantsJson(req)
|
|
187
|
+
let probe: ReturnType<typeof Table.make> | undefined
|
|
188
|
+
try { probe = R.table(Table.make()) }
|
|
189
|
+
catch { continue } // user-side throw — neither flag applies
|
|
1314
190
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
const form = selectForm(findForms(elements), formId)
|
|
1319
|
-
if (!form) {
|
|
1320
|
-
if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
|
|
1321
|
-
res.status(404)
|
|
1322
|
-
return res.send('No form found on page')
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
const result = await dispatchFormSubmit(form, values, {
|
|
1326
|
-
values,
|
|
1327
|
-
basePath: base,
|
|
1328
|
-
...(R.model ? { parentModel: R.model } : {}),
|
|
1329
|
-
})
|
|
1330
|
-
|
|
1331
|
-
if (!result.ok) {
|
|
1332
|
-
if (json) {
|
|
1333
|
-
res.status(422)
|
|
1334
|
-
return res.json({ ok: false, errors: result.errors })
|
|
1335
|
-
}
|
|
1336
|
-
// Re-render through the same builder so the page is identical to GET,
|
|
1337
|
-
// just with values + errors prefilled.
|
|
1338
|
-
const data = await resourceCreateData(pilotiq, slug, { values, errors: result.errors })
|
|
1339
|
-
res.status(422)
|
|
1340
|
-
return view('pilotiq.resource-create', data ?? {})
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
const recordId = (result.record as { id?: unknown })?.id
|
|
1344
|
-
// "Create & create another" — when the secondary submit fired,
|
|
1345
|
-
// route back to the create page with a fresh form. Skips any
|
|
1346
|
-
// user-supplied `redirectAfterSave`: the user clicked the
|
|
1347
|
-
// button asking explicitly to create another, so the
|
|
1348
|
-
// continue-intent wins. `force: true` tells the SPA-mode
|
|
1349
|
-
// FormRenderer to navigate even though the redirect URL
|
|
1350
|
-
// matches the current page (otherwise the same-URL skip
|
|
1351
|
-
// would preserve the just-submitted values on screen).
|
|
1352
|
-
const fallback = continueCreate
|
|
1353
|
-
? createUrl
|
|
1354
|
-
: recordId !== undefined ? `${resourceBase}/${String(recordId)}/edit` : `${resourceBase}`
|
|
1355
|
-
const redirect = continueCreate
|
|
1356
|
-
? createUrl
|
|
1357
|
-
: normalizeRedirect(result.redirect, base) ?? fallback
|
|
1358
|
-
if (json) {
|
|
1359
|
-
return res.json({
|
|
1360
|
-
ok: true,
|
|
1361
|
-
redirect,
|
|
1362
|
-
...(continueCreate ? { force: true } : {}),
|
|
1363
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
1364
|
-
})
|
|
1365
|
-
}
|
|
1366
|
-
flashNotifications(req, result.notifications)
|
|
1367
|
-
return res.redirect(redirect, 303)
|
|
1368
|
-
})
|
|
1369
|
-
|
|
1370
|
-
// Action dispatch — POST ${createUrl}/_action/:actionName
|
|
1371
|
-
// Handles both page-level handler-style actions AND Repeater /
|
|
1372
|
-
// Builder `extraItemActions` rows. The latter pass `_rowPath` in
|
|
1373
|
-
// the body so the dispatcher hydrates `ctx.row` from the form's
|
|
1374
|
-
// coerced values.
|
|
1375
|
-
router.post(`${createUrl}/_action/:actionName`, async (req, res) => {
|
|
1376
|
-
const user = await pilotiq.resolveUser(req)
|
|
1377
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1378
|
-
if (!await checkPolicy(() => R.canCreate(user))) return forbidden(res, wantsJson(req))
|
|
1379
|
-
|
|
1380
|
-
const actionName = req.params['actionName']!
|
|
1381
|
-
const json = wantsJson(req)
|
|
1382
|
-
const body = await readFormBody(req)
|
|
1383
|
-
const input = parseActionBody(body)
|
|
1384
|
-
|
|
1385
|
-
const ctx: SchemaContext = { mode: 'create', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
1386
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1387
|
-
tagActionDispatch(elements, createUrl)
|
|
1388
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
1389
|
-
if (!target) {
|
|
1390
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
1391
|
-
res.status(404)
|
|
1392
|
-
return res.send(`Action "${actionName}" not found on ${R.label}`)
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
const result = await dispatchAction(target.action, {
|
|
1396
|
-
...input,
|
|
1397
|
-
request: req,
|
|
1398
|
-
user,
|
|
1399
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
1400
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
1401
|
-
})
|
|
1402
|
-
if (!result.ok) {
|
|
1403
|
-
if (json) {
|
|
1404
|
-
res.status(result.errors ? 422 : 500)
|
|
1405
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
1406
|
-
}
|
|
1407
|
-
res.status(500)
|
|
1408
|
-
return res.send(result.error)
|
|
1409
|
-
}
|
|
1410
|
-
if (result.download) return sendDownload(res, result.download)
|
|
1411
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? createUrl
|
|
1412
|
-
if (json) {
|
|
1413
|
-
return res.json({
|
|
1414
|
-
ok: true,
|
|
1415
|
-
redirect,
|
|
1416
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
1417
|
-
})
|
|
1418
|
-
}
|
|
1419
|
-
flashNotifications(req, result.notifications)
|
|
1420
|
-
return res.redirect(redirect, 303)
|
|
1421
|
-
})
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// View — GET ${resourceBase}/:id (literal `create` matches first via
|
|
1425
|
-
// Hono's literal-over-param routing, so `:id` only catches everything else.)
|
|
1426
|
-
if (pages.view) {
|
|
1427
|
-
router.get(`${resourceBase}/:id`, async (req, res) => {
|
|
1428
|
-
const recordId = req.params['id']!
|
|
1429
|
-
// Hono routes both `/create` and `/:id` against this slot; only the
|
|
1430
|
-
// literal `create` segment hits the create route. Defensive guard:
|
|
1431
|
-
if (recordId === 'create') return // handled by create route
|
|
1432
|
-
|
|
1433
|
-
const user = await pilotiq.resolveUser(req)
|
|
1434
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1435
|
-
// Load the record once so canView can inspect it. Stub `{ id }`
|
|
1436
|
-
// when the resource has no model wired — the user-authored
|
|
1437
|
-
// predicate gets to decide what to do with it.
|
|
1438
|
-
const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1439
|
-
if (!await checkPolicy(() => R.canView(user, record))) return forbidden(res, wantsJson(req))
|
|
1440
|
-
|
|
1441
|
-
const data = await resourceViewData(pilotiq, slug, recordId, req)
|
|
1442
|
-
return view('pilotiq.resource-view', data ?? {})
|
|
1443
|
-
})
|
|
1444
|
-
|
|
1445
|
-
// Delete — POST ${resourceBase}/:id/delete
|
|
1446
|
-
router.post(`${resourceBase}/:id/delete`, async (req, res) => {
|
|
1447
|
-
const recordId = req.params['id']!
|
|
1448
|
-
const json = wantsJson(req)
|
|
1449
|
-
const indexUrl = `${resourceBase}`
|
|
1450
|
-
|
|
1451
|
-
const user = await pilotiq.resolveUser(req)
|
|
1452
|
-
if (!await policyAccess(R, user)) return forbidden(res, json)
|
|
1453
|
-
const record = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1454
|
-
if (!await checkPolicy(() => R.canDelete(user, record))) return forbidden(res, json)
|
|
1455
|
-
|
|
1456
|
-
try {
|
|
1457
|
-
await R.deleteRecord(recordId)
|
|
1458
|
-
} catch (err) {
|
|
1459
|
-
const message = err instanceof Error ? err.message : 'Delete failed'
|
|
1460
|
-
if (json) {
|
|
1461
|
-
res.status(500)
|
|
1462
|
-
return res.json({ ok: false, error: message })
|
|
1463
|
-
}
|
|
1464
|
-
res.status(500)
|
|
1465
|
-
return res.send(message)
|
|
1466
|
-
}
|
|
1467
|
-
if (json) {
|
|
1468
|
-
// Build a synthetic deletion notification so the SPA path gets
|
|
1469
|
-
// the same toast UX as a JSON-dispatched action handler. The
|
|
1470
|
-
// form-method 303 path doesn't have the form-lifecycle toast
|
|
1471
|
-
// pipeline, so we surface confirmation here. Plan #13: use
|
|
1472
|
-
// "moved to trash" framing on soft-delete resources so users
|
|
1473
|
-
// know the row is recoverable.
|
|
1474
|
-
const title = R.softDeletes
|
|
1475
|
-
? `${R.labelSingular} moved to trash`
|
|
1476
|
-
: `${R.labelSingular} deleted`
|
|
1477
|
-
const notifications = [
|
|
1478
|
-
{ id: `n-delete-${recordId}-${Date.now()}`, type: 'success', title },
|
|
1479
|
-
]
|
|
1480
|
-
return res.json({ ok: true, redirect: indexUrl, notifications })
|
|
1481
|
-
}
|
|
1482
|
-
return res.redirect(indexUrl, 303)
|
|
1483
|
-
})
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
// ─── Plan #13 soft-delete routes (restore / force-delete) ─────
|
|
1487
|
-
// Both routes opt-in only when `Resource.softDeletes = true`. They
|
|
1488
|
-
// load the target row through `withTrashed()` so the lookup finds
|
|
1489
|
-
// currently-trashed records (which the default scope hides). The
|
|
1490
|
-
// `restore` route undoes a prior soft-delete; `force-delete`
|
|
1491
|
-
// bypasses soft-delete entirely.
|
|
1492
|
-
if (R.softDeletes) {
|
|
1493
|
-
// Boot-time guard — yell loudly if the rudder ORM model isn't
|
|
1494
|
-
// wired up. Keeps "why didn't restore work?" debug sessions
|
|
1495
|
-
// short. Pilotiq's flag and rudder's flag are deliberately
|
|
1496
|
-
// independent (see plan doc).
|
|
1497
|
-
if (!R.model) {
|
|
1498
|
-
throw new Error(
|
|
1499
|
-
`[Pilotiq] ${R.name}: softDeletes = true requires a Resource.model. Wire one up or unset softDeletes.`,
|
|
1500
|
-
)
|
|
1501
|
-
}
|
|
1502
|
-
if (typeof R.model.restore !== 'function' || typeof R.model.forceDelete !== 'function') {
|
|
191
|
+
const probeColumn = probe.getReorderableColumn()
|
|
192
|
+
if (probeColumn !== undefined) {
|
|
193
|
+
if (!R.model || typeof R.model.reorder !== 'function') {
|
|
1503
194
|
throw new Error(
|
|
1504
|
-
`[Pilotiq] ${R.name}
|
|
1505
|
-
`
|
|
195
|
+
`[Pilotiq] ${R.name}.table() calls reorderable("${probeColumn}") but the bound model has no reorder(ids) method. ` +
|
|
196
|
+
`Implement \`async reorder(ids)\` on the rudder Model (or remove the .reorderable() call).`,
|
|
1506
197
|
)
|
|
1507
198
|
}
|
|
1508
|
-
|
|
1509
|
-
const M = R.model
|
|
1510
|
-
const pk = (M.primaryKey ?? 'id') as string
|
|
1511
|
-
|
|
1512
|
-
// Helper — load a row through `withTrashed` so currently-trashed
|
|
1513
|
-
// records resolve. Returns undefined when the lookup misses (route
|
|
1514
|
-
// converts to 404).
|
|
1515
|
-
const loadTrashable = async (id: string): Promise<unknown> => {
|
|
1516
|
-
const q = M.query()
|
|
1517
|
-
if (typeof q.withTrashed !== 'function') return M.find(id).catch(() => undefined)
|
|
1518
|
-
const result = await q.withTrashed()
|
|
1519
|
-
.where(pk, '=', id)
|
|
1520
|
-
.paginate(1, 1)
|
|
1521
|
-
.catch(() => ({ data: [] as unknown[] }))
|
|
1522
|
-
return Array.isArray(result.data) ? result.data[0] : undefined
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
// Restore — POST ${resourceBase}/:id/restore
|
|
1526
|
-
router.post(`${resourceBase}/:id/restore`, async (req, res) => {
|
|
1527
|
-
const recordId = req.params['id']!
|
|
1528
|
-
const json = wantsJson(req)
|
|
1529
|
-
const indexUrl = `${resourceBase}`
|
|
1530
|
-
|
|
1531
|
-
const user = await pilotiq.resolveUser(req)
|
|
1532
|
-
if (!await policyAccess(R, user)) return forbidden(res, json)
|
|
1533
|
-
const record = await loadTrashable(recordId)
|
|
1534
|
-
if (!record) {
|
|
1535
|
-
res.status(404)
|
|
1536
|
-
return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
|
|
1537
|
-
}
|
|
1538
|
-
if (!await checkPolicy(() => R.canRestore(user, record))) return forbidden(res, json)
|
|
1539
|
-
|
|
1540
|
-
try {
|
|
1541
|
-
await M.restore!(recordId)
|
|
1542
|
-
} catch (err) {
|
|
1543
|
-
const message = err instanceof Error ? err.message : 'Restore failed'
|
|
1544
|
-
res.status(500)
|
|
1545
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
if (json) {
|
|
1549
|
-
const notifications = [
|
|
1550
|
-
{ id: `n-restore-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} restored` },
|
|
1551
|
-
]
|
|
1552
|
-
return res.json({ ok: true, redirect: indexUrl, notifications })
|
|
1553
|
-
}
|
|
1554
|
-
return res.redirect(indexUrl, 303)
|
|
1555
|
-
})
|
|
1556
|
-
|
|
1557
|
-
// Force-delete — POST ${resourceBase}/:id/force-delete
|
|
1558
|
-
router.post(`${resourceBase}/:id/force-delete`, async (req, res) => {
|
|
1559
|
-
const recordId = req.params['id']!
|
|
1560
|
-
const json = wantsJson(req)
|
|
1561
|
-
const indexUrl = `${resourceBase}`
|
|
1562
|
-
|
|
1563
|
-
const user = await pilotiq.resolveUser(req)
|
|
1564
|
-
if (!await policyAccess(R, user)) return forbidden(res, json)
|
|
1565
|
-
const record = await loadTrashable(recordId)
|
|
1566
|
-
if (!record) {
|
|
1567
|
-
res.status(404)
|
|
1568
|
-
return json ? res.json({ ok: false, error: 'Not found' }) : res.send('Not found')
|
|
1569
|
-
}
|
|
1570
|
-
if (!await checkPolicy(() => R.canForceDelete(user, record))) return forbidden(res, json)
|
|
1571
|
-
|
|
1572
|
-
try {
|
|
1573
|
-
await M.forceDelete!(recordId)
|
|
1574
|
-
} catch (err) {
|
|
1575
|
-
const message = err instanceof Error ? err.message : 'Force-delete failed'
|
|
1576
|
-
res.status(500)
|
|
1577
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
if (json) {
|
|
1581
|
-
const notifications = [
|
|
1582
|
-
{ id: `n-fdelete-${recordId}-${Date.now()}`, type: 'success', title: `${R.labelSingular} permanently deleted` },
|
|
1583
|
-
]
|
|
1584
|
-
return res.json({ ok: true, redirect: indexUrl, notifications })
|
|
1585
|
-
}
|
|
1586
|
-
return res.redirect(indexUrl, 303)
|
|
1587
|
-
})
|
|
199
|
+
reorderEnabled.set(R.getSlug(), probeColumn)
|
|
1588
200
|
}
|
|
1589
201
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
if (!await checkPolicy(() => R.canEdit(user, record))) return forbidden(res, wantsJson(req))
|
|
1600
|
-
|
|
1601
|
-
const data = await resourceEditData(pilotiq, slug, recordId, undefined, req)
|
|
1602
|
-
return view('pilotiq.resource-edit', data ?? {})
|
|
1603
|
-
})
|
|
1604
|
-
|
|
1605
|
-
// Edit — POST ${resourceBase}/:id/edit
|
|
1606
|
-
router.post(`${resourceBase}/:id/edit`, async (req, res) => {
|
|
1607
|
-
const recordId = req.params['id']!
|
|
1608
|
-
const editUrl = `${resourceBase}/${recordId}/edit`
|
|
1609
|
-
const body = await readFormBody(req)
|
|
1610
|
-
const { values, formId } = splitMeta(body)
|
|
1611
|
-
const json = wantsJson(req)
|
|
1612
|
-
|
|
1613
|
-
const user = await pilotiq.resolveUser(req)
|
|
1614
|
-
if (!await policyAccess(R, user)) return forbidden(res, json)
|
|
1615
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1616
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, json)
|
|
1617
|
-
|
|
1618
|
-
const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
1619
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1620
|
-
tagFormActions(elements, editUrl)
|
|
1621
|
-
const form = selectForm(findForms(elements), formId)
|
|
1622
|
-
if (!form) {
|
|
1623
|
-
if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
|
|
1624
|
-
res.status(404)
|
|
1625
|
-
return res.send('No form found on page')
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
// Try to load the record so validators with cross-field rules see it.
|
|
1629
|
-
let record: unknown = undefined
|
|
1630
|
-
if (form.getLoadRecord()) {
|
|
1631
|
-
try { record = await form.getLoadRecord()!(recordId, { values }) } catch { /* ignore */ }
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
const result = await dispatchFormSubmit(
|
|
1635
|
-
form,
|
|
1636
|
-
values,
|
|
1637
|
-
{
|
|
1638
|
-
values,
|
|
1639
|
-
basePath: base,
|
|
1640
|
-
...(record !== undefined ? { record } : {}),
|
|
1641
|
-
...(R.model ? { parentModel: R.model } : {}),
|
|
1642
|
-
},
|
|
202
|
+
const hasEditable = (probe.getChildren() ?? [])
|
|
203
|
+
.some(c => c instanceof Column && c.isEditable())
|
|
204
|
+
if (hasEditable) {
|
|
205
|
+
if (!R.model || typeof R.model.update !== 'function') {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`[Pilotiq] ${R.name}.table() declares an editable cell column ` +
|
|
208
|
+
`(TextInputColumn / ToggleColumn / SelectColumn) but the bound ` +
|
|
209
|
+
`model has no update(id, data) method. Set Resource.model = M ` +
|
|
210
|
+
`(rudder ORM convention) or drop the editable column.`,
|
|
1643
211
|
)
|
|
1644
|
-
|
|
1645
|
-
if (!result.ok) {
|
|
1646
|
-
if (json) {
|
|
1647
|
-
res.status(422)
|
|
1648
|
-
return res.json({ ok: false, errors: result.errors })
|
|
1649
|
-
}
|
|
1650
|
-
const data = await resourceEditData(pilotiq, slug, recordId, { values, errors: result.errors })
|
|
1651
|
-
res.status(422)
|
|
1652
|
-
return view('pilotiq.resource-edit', data ?? {})
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
|
|
1656
|
-
if (json) {
|
|
1657
|
-
return res.json({
|
|
1658
|
-
ok: true,
|
|
1659
|
-
redirect,
|
|
1660
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
1661
|
-
})
|
|
1662
|
-
}
|
|
1663
|
-
flashNotifications(req, result.notifications)
|
|
1664
|
-
return res.redirect(redirect, 303)
|
|
1665
|
-
})
|
|
1666
|
-
|
|
1667
|
-
// Action dispatch — POST ${editUrl}/_action/:actionName
|
|
1668
|
-
// Same shape as the create-page _action route. The `:id` segment
|
|
1669
|
-
// gates record-aware policy (canEdit per record); row-scoped
|
|
1670
|
-
// dispatch reuses the form schema we resolve here for `coerceFormValues`.
|
|
1671
|
-
router.post(`${resourceBase}/:id/_action/:actionName`, async (req, res) => {
|
|
1672
|
-
const recordId = req.params['id']!
|
|
1673
|
-
// Hono routes `/edit` and `/delete` against this slot too — bail
|
|
1674
|
-
// out so the dedicated handlers downstream pick them up. The
|
|
1675
|
-
// `:actionName` capture catches anything; the explicit guard
|
|
1676
|
-
// mirrors the view-route `recordId === 'create'` defensive branch.
|
|
1677
|
-
const actionName = req.params['actionName']!
|
|
1678
|
-
|
|
1679
|
-
const user = await pilotiq.resolveUser(req)
|
|
1680
|
-
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
1681
|
-
const policyRecord = R.model ? await findRecord(R, recordId, { user }).catch(() => undefined) : { id: recordId }
|
|
1682
|
-
if (!await checkPolicy(() => R.canEdit(user, policyRecord))) return forbidden(res, wantsJson(req))
|
|
1683
|
-
|
|
1684
|
-
const json = wantsJson(req)
|
|
1685
|
-
const body = await readFormBody(req)
|
|
1686
|
-
const input = parseActionBody(body)
|
|
1687
|
-
|
|
1688
|
-
const editUrl = `${resourceBase}/${recordId}/edit`
|
|
1689
|
-
const ctx: SchemaContext = { mode: 'edit', recordId, basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
1690
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1691
|
-
tagActionDispatch(elements, editUrl)
|
|
1692
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
1693
|
-
if (!target) {
|
|
1694
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
1695
|
-
res.status(404)
|
|
1696
|
-
return res.send(`Action "${actionName}" not found on ${R.label}`)
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
const resolveRecord: ResolveRecord | undefined = R.model
|
|
1700
|
-
? (id: string) => findRecord(R, id, { user })
|
|
1701
|
-
: undefined
|
|
1702
|
-
|
|
1703
|
-
const result = await dispatchAction(target.action, {
|
|
1704
|
-
...input,
|
|
1705
|
-
request: req,
|
|
1706
|
-
user,
|
|
1707
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
1708
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
1709
|
-
}, resolveRecord)
|
|
1710
|
-
if (!result.ok) {
|
|
1711
|
-
if (json) {
|
|
1712
|
-
res.status(result.errors ? 422 : 500)
|
|
1713
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
1714
|
-
}
|
|
1715
|
-
res.status(500)
|
|
1716
|
-
return res.send(result.error)
|
|
1717
|
-
}
|
|
1718
|
-
if (result.download) return sendDownload(res, result.download)
|
|
1719
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
|
|
1720
|
-
if (json) {
|
|
1721
|
-
return res.json({
|
|
1722
|
-
ok: true,
|
|
1723
|
-
redirect,
|
|
1724
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
1725
|
-
})
|
|
1726
|
-
}
|
|
1727
|
-
flashNotifications(req, result.notifications)
|
|
1728
|
-
return res.redirect(redirect, 303)
|
|
1729
|
-
})
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
// ── Plan #11 relation manager routes ───────────────
|
|
1733
|
-
// Per-manager: list, create (GET/POST), edit (GET/POST), delete (POST).
|
|
1734
|
-
// Mounted under ${resourceBase}/:id/${rel} — the `:id` segment is the
|
|
1735
|
-
// PARENT record id; the `:childId` segment (where present) is the
|
|
1736
|
-
// related record's id. Authorization runs in two layers: parent
|
|
1737
|
-
// canAccess + canEdit(parent), then manager-scoped can*.
|
|
1738
|
-
for (const M of R.relations()) {
|
|
1739
|
-
const rel = M.getRelationship()
|
|
1740
|
-
const parentBase = `${resourceBase}/:id/${rel}`
|
|
1741
|
-
|
|
1742
|
-
// Read the relation type once at registration so the (R, M)-
|
|
1743
|
-
// scoped closures all see the same mode without re-reading the
|
|
1744
|
-
// relations map per request. `R.model` is asserted by
|
|
1745
|
-
// `requireParent` at request time; here it may legitimately be
|
|
1746
|
-
// missing during late binding, in which case we fall back to
|
|
1747
|
-
// 'hasMany' (the safe default — no special action injection / no
|
|
1748
|
-
// factory short-circuiting). See `normalizeRelationMode` for the
|
|
1749
|
-
// M2M / polymorphic mappings.
|
|
1750
|
-
const relationType = R.model ? getRelationType(R.model, rel) : 'hasMany'
|
|
1751
|
-
const mode: RelationMode = normalizeRelationMode(relationType)
|
|
1752
|
-
|
|
1753
|
-
// Common policy prelude: load parent, gate access. Returns the
|
|
1754
|
-
// parent record on success or a thrown 403/404 response. Returns
|
|
1755
|
-
// `undefined` when the route should bail out (response already sent).
|
|
1756
|
-
const requireParent = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{ user: unknown; parent: unknown; recordId: string } | undefined> => {
|
|
1757
|
-
const recordId = req.params['id']!
|
|
1758
|
-
const user = await pilotiq.resolveUser(req)
|
|
1759
|
-
if (!await policyAccess(R, user)) { forbidden(res, json); return undefined }
|
|
1760
|
-
if (!R.model) {
|
|
1761
|
-
res.status(500)
|
|
1762
|
-
if (json) res.json({ ok: false, error: `Resource "${R.name}" has relations but no static model` })
|
|
1763
|
-
else res.send(`Resource "${R.name}" has relations but no static model`)
|
|
1764
|
-
return undefined
|
|
1765
|
-
}
|
|
1766
|
-
const parent = await findRecord(R, recordId, { user }).catch(() => undefined)
|
|
1767
|
-
if (!parent) { res.status(404); if (json) res.json({ ok: false, error: 'Parent not found' }); else res.send('Parent not found'); return undefined }
|
|
1768
|
-
if (!await checkPolicy(() => R.canEdit(user, parent))) { forbidden(res, json); return undefined }
|
|
1769
|
-
return { user, parent, recordId }
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
// List — GET ${resourceBase}/:id/${rel}
|
|
1773
|
-
// Manager-level canViewAny is enforced inside relationManagerData via
|
|
1774
|
-
// safeManagerPolicy (with related-resource fall-through). We just
|
|
1775
|
-
// surface the {ok:false,status:403} from the data builder as 403.
|
|
1776
|
-
router.get(parentBase, async (req, res) => {
|
|
1777
|
-
const json = wantsJson(req)
|
|
1778
|
-
const ctx = await requireParent(req, res, json)
|
|
1779
|
-
if (!ctx) return
|
|
1780
|
-
const data = await relationManagerData(pilotiq, {
|
|
1781
|
-
kind: 'relation-list', slug, recordId: ctx.recordId, relationship: rel, query: req.query as Record<string, string>,
|
|
1782
|
-
}, req)
|
|
1783
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
1784
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
1785
|
-
return view('pilotiq.relation-list', data)
|
|
1786
|
-
})
|
|
1787
|
-
|
|
1788
|
-
// Create — GET ${resourceBase}/:id/${rel}/create
|
|
1789
|
-
router.get(`${parentBase}/create`, async (req, res) => {
|
|
1790
|
-
const json = wantsJson(req)
|
|
1791
|
-
const ctx = await requireParent(req, res, json)
|
|
1792
|
-
if (!ctx) return
|
|
1793
|
-
const data = await relationManagerData(pilotiq, {
|
|
1794
|
-
kind: 'relation-create', slug, recordId: ctx.recordId, relationship: rel,
|
|
1795
|
-
}, req)
|
|
1796
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
1797
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
1798
|
-
return view('pilotiq.relation-create', data)
|
|
1799
|
-
})
|
|
1800
|
-
|
|
1801
|
-
// Create submit — POST ${resourceBase}/:id/${rel}/create
|
|
1802
|
-
router.post(`${parentBase}/create`, async (req, res) => {
|
|
1803
|
-
const json = wantsJson(req)
|
|
1804
|
-
const pre = await requireParent(req, res, json)
|
|
1805
|
-
if (!pre) return
|
|
1806
|
-
|
|
1807
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
1808
|
-
if (!Related) {
|
|
1809
|
-
res.status(500)
|
|
1810
|
-
const msg = `RelationManager ${M.name}: cannot resolve related Resource for create`
|
|
1811
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
1812
|
-
}
|
|
1813
|
-
if (!await safeManagerPolicy(M, 'canCreate', Related, pre.user, pre.parent)) return forbidden(res, json)
|
|
1814
|
-
|
|
1815
|
-
const body = await readFormBody(req)
|
|
1816
|
-
const { values } = splitMeta(body)
|
|
1817
|
-
|
|
1818
|
-
const createUrl = `${parentBase}/create`.replace(':id', pre.recordId)
|
|
1819
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
1820
|
-
const form = M.form(Form.make(), {
|
|
1821
|
-
basePath: base,
|
|
1822
|
-
parentSlug: slug,
|
|
1823
|
-
parentId: pre.recordId,
|
|
1824
|
-
relationship: rel,
|
|
1825
|
-
parentRecord: pre.parent,
|
|
1826
|
-
related: Related,
|
|
1827
|
-
mode,
|
|
1828
|
-
})
|
|
1829
|
-
if (Related.model) {
|
|
1830
|
-
if (!form.getSave()) form.save(modelSave(Related.model))
|
|
1831
|
-
if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
// Polymorphic auto-injection — when the parent's relation entry
|
|
1835
|
-
// is `morphMany` / `morphOne`, fill the `{morphName}Id` and
|
|
1836
|
-
// `{morphName}Type` columns on the child before persistence.
|
|
1837
|
-
// Compose with any user-supplied `mutateDataBeforeCreate` and
|
|
1838
|
-
// run AFTER it so morph values overwrite anything the form
|
|
1839
|
-
// body or user hook might have set — the parent record is the
|
|
1840
|
-
// single source of truth for who owns the new child, and a
|
|
1841
|
-
// submitted form field cannot be allowed to tamper with that.
|
|
1842
|
-
if (mode === 'morphMany' && R.model) {
|
|
1843
|
-
const morphDesc = getMorphRelationDescriptor(R.model, rel)
|
|
1844
|
-
if (!morphDesc) {
|
|
1845
|
-
res.status(500)
|
|
1846
|
-
const msg = `RelationManager ${M.name}: relations[${JSON.stringify(rel)}] reports a polymorphic type but is missing morphName.`
|
|
1847
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
1848
|
-
}
|
|
1849
|
-
const morphPayload = computeMorphPayload(pre.parent, morphDesc)
|
|
1850
|
-
const existing = form.getMutateDataBeforeCreate()
|
|
1851
|
-
form.mutateDataBeforeCreate(async (data, ctx) => {
|
|
1852
|
-
const next = existing ? await existing(data, ctx) : data
|
|
1853
|
-
return { ...next, ...morphPayload }
|
|
1854
|
-
})
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
// Stamp parent context onto FormContext so user hooks
|
|
1858
|
-
// (mutateDataBeforeCreate, redirectAfterSave, etc.) can default
|
|
1859
|
-
// foreign-key columns or build URLs from the parent.
|
|
1860
|
-
const formCtx = {
|
|
1861
|
-
values,
|
|
1862
|
-
basePath: base,
|
|
1863
|
-
parent: pre.parent,
|
|
1864
|
-
parentId: pre.recordId,
|
|
1865
|
-
relationship: rel,
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
const result = await dispatchFormSubmit(form, values, formCtx)
|
|
1869
|
-
if (!result.ok) {
|
|
1870
|
-
if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
|
|
1871
|
-
const data = await relationManagerData(pilotiq, {
|
|
1872
|
-
kind: 'relation-create', slug, recordId: pre.recordId, relationship: rel,
|
|
1873
|
-
prefill: { values, errors: result.errors ?? {} },
|
|
1874
|
-
}, req)
|
|
1875
|
-
res.status(422)
|
|
1876
|
-
return view('pilotiq.relation-create', data ?? {})
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
|
|
1880
|
-
if (json) {
|
|
1881
|
-
return res.json({
|
|
1882
|
-
ok: true, redirect,
|
|
1883
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
1884
|
-
})
|
|
1885
|
-
}
|
|
1886
|
-
flashNotifications(req, result.notifications)
|
|
1887
|
-
return res.redirect(redirect, 303)
|
|
1888
|
-
})
|
|
1889
|
-
|
|
1890
|
-
// View — GET ${resourceBase}/:id/${rel}/:childId (Phase A nested
|
|
1891
|
-
// resources). 5-segment URL. The literal `${parentBase}/create`
|
|
1892
|
-
// route is registered above and Hono prefers static segments over
|
|
1893
|
-
// wildcards, but the `childId === 'create'` guard belt-and-suspenders
|
|
1894
|
-
// against any router that doesn't.
|
|
1895
|
-
router.get(`${parentBase}/:childId`, async (req, res) => {
|
|
1896
|
-
const json = wantsJson(req)
|
|
1897
|
-
const pre = await requireParent(req, res, json)
|
|
1898
|
-
if (!pre) return
|
|
1899
|
-
const childId = req.params['childId']!
|
|
1900
|
-
if (childId === 'create') { res.status(404); return res.send('Not found') }
|
|
1901
|
-
const data = await relationManagerData(pilotiq, {
|
|
1902
|
-
kind: 'relation-view', slug, recordId: pre.recordId, relationship: rel, childId,
|
|
1903
|
-
}, req)
|
|
1904
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
1905
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
1906
|
-
return view('pilotiq.relation-view', data)
|
|
1907
|
-
})
|
|
1908
|
-
|
|
1909
|
-
// Edit — GET ${resourceBase}/:id/${rel}/:childId/edit
|
|
1910
|
-
router.get(`${parentBase}/:childId/edit`, async (req, res) => {
|
|
1911
|
-
const json = wantsJson(req)
|
|
1912
|
-
const pre = await requireParent(req, res, json)
|
|
1913
|
-
if (!pre) return
|
|
1914
|
-
const childId = req.params['childId']!
|
|
1915
|
-
const data = await relationManagerData(pilotiq, {
|
|
1916
|
-
kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
|
|
1917
|
-
}, req)
|
|
1918
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
1919
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
1920
|
-
return view('pilotiq.relation-edit', data)
|
|
1921
|
-
})
|
|
1922
|
-
|
|
1923
|
-
// Edit submit — POST ${resourceBase}/:id/${rel}/:childId/edit
|
|
1924
|
-
router.post(`${parentBase}/:childId/edit`, async (req, res) => {
|
|
1925
|
-
const json = wantsJson(req)
|
|
1926
|
-
const pre = await requireParent(req, res, json)
|
|
1927
|
-
if (!pre) return
|
|
1928
|
-
const childId = req.params['childId']!
|
|
1929
|
-
|
|
1930
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
1931
|
-
if (!Related?.model) {
|
|
1932
|
-
res.status(500)
|
|
1933
|
-
const msg = `RelationManager ${M.name}: cannot resolve related Resource for edit`
|
|
1934
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
// IDOR + load via the data builder's gating: re-use it to verify
|
|
1938
|
-
// the child belongs to this parent, then do the form submit.
|
|
1939
|
-
const childCheck = await relationManagerData(pilotiq, {
|
|
1940
|
-
kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
|
|
1941
|
-
}, req)
|
|
1942
|
-
if (childCheck === null) { res.status(404); return res.send('Not found') }
|
|
1943
|
-
if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
|
|
1944
|
-
|
|
1945
|
-
const body = await readFormBody(req)
|
|
1946
|
-
const { values } = splitMeta(body)
|
|
1947
|
-
|
|
1948
|
-
const editUrl = `${parentBase}/${childId}/edit`.replace(':id', pre.recordId)
|
|
1949
|
-
const form = M.form(Form.make(), {
|
|
1950
|
-
basePath: base,
|
|
1951
|
-
parentSlug: slug,
|
|
1952
|
-
parentId: pre.recordId,
|
|
1953
|
-
relationship: rel,
|
|
1954
|
-
parentRecord: pre.parent,
|
|
1955
|
-
related: Related,
|
|
1956
|
-
mode,
|
|
1957
|
-
})
|
|
1958
|
-
if (!form.getSave()) form.save(modelSave(Related.model))
|
|
1959
|
-
if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
|
|
1960
|
-
|
|
1961
|
-
// Re-load child for FormContext so cross-field validators see it.
|
|
1962
|
-
let child: unknown = undefined
|
|
1963
|
-
try { child = await findRecord(Related, childId, { user: pre.user }) } catch { /* ignore */ }
|
|
1964
|
-
if (!child) { res.status(404); return res.send('Not found') }
|
|
1965
|
-
|
|
1966
|
-
// Polymorphic re-stamp on update — same posture as the create
|
|
1967
|
-
// path. Re-injecting the morph columns from the live parent
|
|
1968
|
-
// record ensures a tampered body (`commentableId=…` /
|
|
1969
|
-
// `commentableType=…` posted by an attacker) can't reassign
|
|
1970
|
-
// the child to another polymorphic parent. Composed AFTER any
|
|
1971
|
-
// user `mutateDataBeforeUpdate` so the framework wins.
|
|
1972
|
-
if (mode === 'morphMany' && R.model) {
|
|
1973
|
-
const morphDesc = getMorphRelationDescriptor(R.model, rel)
|
|
1974
|
-
if (morphDesc) {
|
|
1975
|
-
const morphPayload = computeMorphPayload(pre.parent, morphDesc)
|
|
1976
|
-
const existing = form.getMutateDataBeforeUpdate()
|
|
1977
|
-
form.mutateDataBeforeUpdate(async (data, ctx) => {
|
|
1978
|
-
const next = existing ? await existing(data, ctx) : data
|
|
1979
|
-
return { ...next, ...morphPayload }
|
|
1980
|
-
})
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
const formCtx = {
|
|
1985
|
-
values,
|
|
1986
|
-
basePath: base,
|
|
1987
|
-
record: child,
|
|
1988
|
-
parent: pre.parent,
|
|
1989
|
-
parentId: pre.recordId,
|
|
1990
|
-
relationship: rel,
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
const result = await dispatchFormSubmit(form, values, formCtx)
|
|
1994
|
-
if (!result.ok) {
|
|
1995
|
-
if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
|
|
1996
|
-
const data = await relationManagerData(pilotiq, {
|
|
1997
|
-
kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
|
|
1998
|
-
prefill: { values, errors: result.errors ?? {} },
|
|
1999
|
-
}, req)
|
|
2000
|
-
res.status(422)
|
|
2001
|
-
return view('pilotiq.relation-edit', data ?? {})
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
|
|
2005
|
-
if (json) {
|
|
2006
|
-
return res.json({
|
|
2007
|
-
ok: true, redirect,
|
|
2008
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
2009
|
-
})
|
|
2010
|
-
}
|
|
2011
|
-
flashNotifications(req, result.notifications)
|
|
2012
|
-
return res.redirect(redirect, 303)
|
|
2013
|
-
})
|
|
2014
|
-
|
|
2015
|
-
// Delete — POST ${resourceBase}/:id/${rel}/:childId/delete
|
|
2016
|
-
router.post(`${parentBase}/:childId/delete`, async (req, res) => {
|
|
2017
|
-
const json = wantsJson(req)
|
|
2018
|
-
const pre = await requireParent(req, res, json)
|
|
2019
|
-
if (!pre) return
|
|
2020
|
-
const childId = req.params['childId']!
|
|
2021
|
-
|
|
2022
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
2023
|
-
if (!Related?.model) {
|
|
2024
|
-
res.status(500)
|
|
2025
|
-
const msg = `RelationManager ${M.name}: cannot resolve related Resource for delete`
|
|
2026
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// Anti-IDOR: re-use the data builder's child-belongs check.
|
|
2030
|
-
const childCheck = await relationManagerData(pilotiq, {
|
|
2031
|
-
kind: 'relation-edit', slug, recordId: pre.recordId, relationship: rel, childId,
|
|
2032
|
-
}, req)
|
|
2033
|
-
if (childCheck === null) { res.status(404); return res.send('Not found') }
|
|
2034
|
-
if ('ok' in childCheck && childCheck.ok === false) return forbidden(res, json)
|
|
2035
|
-
|
|
2036
|
-
const child = await findRecord(Related, childId, { user: pre.user }).catch(() => undefined)
|
|
2037
|
-
if (!child) { res.status(404); return res.send('Not found') }
|
|
2038
|
-
|
|
2039
|
-
if (!await safeManagerPolicy(M, 'canDelete', Related, pre.user, pre.parent, child)) return forbidden(res, json)
|
|
2040
|
-
|
|
2041
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
2042
|
-
try {
|
|
2043
|
-
await Related.model.delete(childId)
|
|
2044
|
-
} catch (err) {
|
|
2045
|
-
const message = err instanceof Error ? err.message : 'Delete failed'
|
|
2046
|
-
res.status(500)
|
|
2047
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
if (json) {
|
|
2051
|
-
const notifications = [
|
|
2052
|
-
{ id: `n-rdelete-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} deleted` },
|
|
2053
|
-
]
|
|
2054
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2055
|
-
}
|
|
2056
|
-
return res.redirect(listUrl, 303)
|
|
2057
|
-
})
|
|
2058
|
-
|
|
2059
|
-
// ── Plan #13 polish — relation restore / force-delete ─────
|
|
2060
|
-
// Mirror the resource-side soft-delete routes, scoped under the
|
|
2061
|
-
// parent record. Both routes opt in only when the related Resource
|
|
2062
|
-
// has `softDeletes = true` AND its model carries `restore` /
|
|
2063
|
-
// `forceDelete`. Two-layer auth: parent canAccess + canEdit, then
|
|
2064
|
-
// manager `canRestore / canForceDelete` (with related-Resource
|
|
2065
|
-
// fall-through). IDOR check re-runs the parent's relation query
|
|
2066
|
-
// through `withTrashed()` so trashed children still resolve.
|
|
2067
|
-
const RelatedForSoft = findRelatedResource(M, R, cfg)
|
|
2068
|
-
if (RelatedForSoft?.softDeletes) {
|
|
2069
|
-
const RM = RelatedForSoft.model
|
|
2070
|
-
if (!RM) {
|
|
2071
|
-
throw new Error(
|
|
2072
|
-
`[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but no model. ` +
|
|
2073
|
-
`Wire one up or unset softDeletes.`,
|
|
2074
|
-
)
|
|
2075
|
-
}
|
|
2076
|
-
if (typeof RM.restore !== 'function' || typeof RM.forceDelete !== 'function') {
|
|
2077
|
-
throw new Error(
|
|
2078
|
-
`[Pilotiq] RelationManager ${M.name} on ${R.name}: related Resource ${RelatedForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
|
|
2079
|
-
`Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
|
|
2080
|
-
)
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
// IDOR-safe load through the parent's relation query, broadened
|
|
2084
|
-
// with `withTrashed()` so currently-trashed children resolve.
|
|
2085
|
-
// Returns undefined when the child doesn't belong to this parent
|
|
2086
|
-
// (under the broadened scope) or the lookup misses.
|
|
2087
|
-
const loadTrashableChild = async (parent: unknown, childId: string): Promise<unknown> => {
|
|
2088
|
-
if (!R.model) return undefined
|
|
2089
|
-
const pk = (RM.primaryKey ?? 'id') as string
|
|
2090
|
-
try {
|
|
2091
|
-
const q: import('./orm/modelDefaults.js').ModelQuery = R.model.relatedQuery
|
|
2092
|
-
? R.model.relatedQuery(parent, rel)
|
|
2093
|
-
: (parent as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(rel)
|
|
2094
|
-
const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
|
|
2095
|
-
const result = await broadened.where(pk, '=', childId).paginate(1, 1)
|
|
2096
|
-
return Array.isArray(result.data) ? result.data[0] : undefined
|
|
2097
|
-
} catch {
|
|
2098
|
-
return undefined
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
// Restore — POST ${resourceBase}/:id/${rel}/:childId/restore
|
|
2103
|
-
router.post(`${parentBase}/:childId/restore`, async (req, res) => {
|
|
2104
|
-
const json = wantsJson(req)
|
|
2105
|
-
const pre = await requireParent(req, res, json)
|
|
2106
|
-
if (!pre) return
|
|
2107
|
-
const childId = req.params['childId']!
|
|
2108
|
-
|
|
2109
|
-
const child = await loadTrashableChild(pre.parent, childId)
|
|
2110
|
-
if (!child) { res.status(404); return res.send('Not found') }
|
|
2111
|
-
|
|
2112
|
-
if (!await safeManagerPolicy(M, 'canRestore', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
|
|
2113
|
-
|
|
2114
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
2115
|
-
try {
|
|
2116
|
-
await RM.restore!(childId)
|
|
2117
|
-
} catch (err) {
|
|
2118
|
-
const message = err instanceof Error ? err.message : 'Restore failed'
|
|
2119
|
-
res.status(500)
|
|
2120
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
if (json) {
|
|
2124
|
-
const notifications = [
|
|
2125
|
-
{ id: `n-rrestore-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} restored` },
|
|
2126
|
-
]
|
|
2127
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2128
|
-
}
|
|
2129
|
-
return res.redirect(listUrl, 303)
|
|
2130
|
-
})
|
|
2131
|
-
|
|
2132
|
-
// Force-delete — POST ${resourceBase}/:id/${rel}/:childId/force-delete
|
|
2133
|
-
router.post(`${parentBase}/:childId/force-delete`, async (req, res) => {
|
|
2134
|
-
const json = wantsJson(req)
|
|
2135
|
-
const pre = await requireParent(req, res, json)
|
|
2136
|
-
if (!pre) return
|
|
2137
|
-
const childId = req.params['childId']!
|
|
2138
|
-
|
|
2139
|
-
const child = await loadTrashableChild(pre.parent, childId)
|
|
2140
|
-
if (!child) { res.status(404); return res.send('Not found') }
|
|
2141
|
-
|
|
2142
|
-
if (!await safeManagerPolicy(M, 'canForceDelete', RelatedForSoft, pre.user, pre.parent, child)) return forbidden(res, json)
|
|
2143
|
-
|
|
2144
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
2145
|
-
try {
|
|
2146
|
-
await RM.forceDelete!(childId)
|
|
2147
|
-
} catch (err) {
|
|
2148
|
-
const message = err instanceof Error ? err.message : 'Force-delete failed'
|
|
2149
|
-
res.status(500)
|
|
2150
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
|
-
if (json) {
|
|
2154
|
-
const notifications = [
|
|
2155
|
-
{ id: `n-rforce-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} permanently deleted` },
|
|
2156
|
-
]
|
|
2157
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2158
|
-
}
|
|
2159
|
-
return res.redirect(listUrl, 303)
|
|
2160
|
-
})
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
// ── M2M follow-up — manager-scoped action dispatch + detach ─────
|
|
2164
|
-
// Two new routes per relation manager. Mounted unconditionally
|
|
2165
|
-
// (even on hasMany managers) because handler-style actions are
|
|
2166
|
-
// useful beyond M2M — any user-defined `Action.handler(...)` on a
|
|
2167
|
-
// manager table needs a place to dispatch. The detach route is
|
|
2168
|
-
// M2M-specific but cheap enough to register either way; non-M2M
|
|
2169
|
-
// managers' `Action.relationDetach` factories return `visible=false`
|
|
2170
|
-
// anyway, so the URL is unreachable in practice.
|
|
2171
|
-
|
|
2172
|
-
// Action dispatch — POST ${parentBase}/_action/:actionName
|
|
2173
|
-
// Resolves the manager's table elements, finds the named action,
|
|
2174
|
-
// and dispatches it with `ctx.relation = { parent, parentId, rel }`
|
|
2175
|
-
// so M2M handlers can call `parent.related(rel).attach / detach`.
|
|
2176
|
-
// Records hydrate against the related model (the rows visible in
|
|
2177
|
-
// the manager's table are related-model records).
|
|
2178
|
-
router.post(`${parentBase}/_action/:actionName`, async (req, res) => {
|
|
2179
|
-
const json = wantsJson(req)
|
|
2180
|
-
const pre = await requireParent(req, res, json)
|
|
2181
|
-
if (!pre) return
|
|
2182
|
-
|
|
2183
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
2184
|
-
const actionName = req.params['actionName']!
|
|
2185
|
-
const body = await readFormBody(req)
|
|
2186
|
-
const input = parseActionBody(body)
|
|
2187
|
-
|
|
2188
|
-
// Rebuild the manager's table so the dispatcher can find the
|
|
2189
|
-
// action by name. Pure recreation — same context the page-data
|
|
2190
|
-
// builder uses — so factories that close over `ctx` (URL,
|
|
2191
|
-
// mode, parent record) see the same shape as at page render.
|
|
2192
|
-
const managerCtx = {
|
|
2193
|
-
basePath: base,
|
|
2194
|
-
parentSlug: slug,
|
|
2195
|
-
parentId: pre.recordId,
|
|
2196
|
-
relationship: rel,
|
|
2197
|
-
parentRecord: pre.parent,
|
|
2198
|
-
related: Related,
|
|
2199
|
-
mode,
|
|
2200
|
-
}
|
|
2201
|
-
const table = M.table(Table.make(), managerCtx)
|
|
2202
|
-
const elements: import('./schema/Element.js').Element[] = [table]
|
|
2203
|
-
// Stamp dispatch URLs so any nested action factories that read
|
|
2204
|
-
// `dispatchUrl` (rare — most read it from the meta at render
|
|
2205
|
-
// time) still see something sensible.
|
|
2206
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
2207
|
-
tagActionDispatch(elements, listUrl)
|
|
2208
|
-
|
|
2209
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
2210
|
-
if (!target) {
|
|
2211
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
2212
|
-
res.status(404)
|
|
2213
|
-
return res.send(`Action "${actionName}" not found on ${M.name}`)
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
|
-
const resolveRecord: ResolveRecord | undefined = Related?.model
|
|
2217
|
-
? (id: string) => Related.model!.find(id)
|
|
2218
|
-
: undefined
|
|
2219
|
-
|
|
2220
|
-
const result = await dispatchAction(target.action, {
|
|
2221
|
-
...input,
|
|
2222
|
-
request: req,
|
|
2223
|
-
user: pre.user,
|
|
2224
|
-
relation: { parent: pre.parent, parentId: pre.recordId, relationship: rel },
|
|
2225
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
2226
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
2227
|
-
}, resolveRecord)
|
|
2228
|
-
|
|
2229
|
-
if (!result.ok) {
|
|
2230
|
-
if (json) {
|
|
2231
|
-
res.status(result.errors ? 422 : 500)
|
|
2232
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
2233
|
-
}
|
|
2234
|
-
res.status(500)
|
|
2235
|
-
return res.send(result.error)
|
|
2236
|
-
}
|
|
2237
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
|
|
2238
|
-
if (json) {
|
|
2239
|
-
return res.json({
|
|
2240
|
-
ok: true,
|
|
2241
|
-
redirect,
|
|
2242
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
2243
|
-
})
|
|
2244
|
-
}
|
|
2245
|
-
flashNotifications(req, result.notifications)
|
|
2246
|
-
return res.redirect(redirect, 303)
|
|
2247
|
-
})
|
|
2248
|
-
|
|
2249
|
-
// Detach — POST ${parentBase}/:childId/_detach
|
|
2250
|
-
// Direct row-action target for `Action.relationDetach`. Removes the
|
|
2251
|
-
// pivot row only; the related record stays in place. IDOR check:
|
|
2252
|
-
// verify the child is currently attached before calling detach so
|
|
2253
|
-
// a tampered URL can't probe random ids.
|
|
2254
|
-
router.post(`${parentBase}/:childId/_detach`, async (req, res) => {
|
|
2255
|
-
const json = wantsJson(req)
|
|
2256
|
-
const pre = await requireParent(req, res, json)
|
|
2257
|
-
if (!pre) return
|
|
2258
|
-
const childId = req.params['childId']!
|
|
2259
|
-
|
|
2260
|
-
if (mode !== 'belongsToMany' && mode !== 'morphToMany' && mode !== 'morphedByMany') {
|
|
2261
|
-
// Detach is meaningless for hasMany — the user wants `delete`.
|
|
2262
|
-
// Surface a clear 404 instead of silently no-op'ing.
|
|
2263
|
-
res.status(404)
|
|
2264
|
-
const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
|
|
2265
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
// Manager-only canDetach: pivot ops don't fall through to the
|
|
2269
|
-
// related Resource. We don't have the related child loaded yet —
|
|
2270
|
-
// pass `undefined` for the per-record arg; canDetach gates on
|
|
2271
|
-
// (user, parent) by default and only sees `record` when a
|
|
2272
|
-
// manager has explicitly overridden with a per-row predicate.
|
|
2273
|
-
// Authors who need per-row gating can detect undefined and either
|
|
2274
|
-
// load the child themselves or short-circuit.
|
|
2275
|
-
// Two distinct accessors are needed under the real
|
|
2276
|
-
// `@rudderjs/orm`:
|
|
2277
|
-
// - `parent.related(rel)` returns a deferred QueryBuilder
|
|
2278
|
-
// with `where / paginate` (IDOR read-side check).
|
|
2279
|
-
// - `parent[rel]()` returns the pivot-mutation accessor with
|
|
2280
|
-
// `attach / detach / sync` (write-side).
|
|
2281
|
-
// Test stubs may collapse both onto the same `parent.related(rel)`
|
|
2282
|
-
// shape — handle that fallback so existing tests keep passing.
|
|
2283
|
-
let child: unknown = undefined
|
|
2284
|
-
const readSide = (pre.parent as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
|
|
2285
|
-
?.related?.(rel)
|
|
2286
|
-
if (!readSide) {
|
|
2287
|
-
res.status(500)
|
|
2288
|
-
const msg = `Parent.related("${rel}") missing — wrong relation type or ORM version?`
|
|
2289
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2290
|
-
}
|
|
2291
|
-
try {
|
|
2292
|
-
// IDOR: confirm the child is currently attached.
|
|
2293
|
-
if (typeof readSide.paginate === 'function') {
|
|
2294
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
2295
|
-
const pk = Related?.model ? getPrimaryKey(Related.model) : 'id'
|
|
2296
|
-
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)
|
|
2297
|
-
child = Array.isArray(out.data) ? out.data[0] : undefined
|
|
2298
|
-
}
|
|
2299
|
-
} catch {
|
|
2300
|
-
// fall through; null child means we couldn't verify — safer to 404
|
|
2301
|
-
}
|
|
2302
|
-
if (child === undefined) { res.status(404); return res.send('Not found') }
|
|
2303
|
-
|
|
2304
|
-
if (!await safeManagerPolicy(M, 'canDetach', undefined, pre.user, pre.parent, child)) return forbidden(res, json)
|
|
2305
|
-
|
|
2306
|
-
// Real ORM: `parent[rel]()` returns the pivot accessor. Test
|
|
2307
|
-
// stubs: `parent.related(rel)` may carry `detach` directly.
|
|
2308
|
-
// Try the prototype-installed instance method first, then fall
|
|
2309
|
-
// back to the read-side shape.
|
|
2310
|
-
let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
|
|
2311
|
-
const inst = (pre.parent as Record<string, unknown>)[rel]
|
|
2312
|
-
if (typeof inst === 'function') {
|
|
2313
|
-
try {
|
|
2314
|
-
const out = (inst as () => unknown).call(pre.parent) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
|
|
2315
|
-
if (out && typeof out.detach === 'function') writeAccessor = out
|
|
2316
|
-
} catch { /* fall through to legacy shape */ }
|
|
2317
|
-
}
|
|
2318
|
-
if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
|
|
2319
|
-
writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
|
|
2320
|
-
}
|
|
2321
|
-
if (!writeAccessor) {
|
|
2322
|
-
res.status(500)
|
|
2323
|
-
const msg = `Pivot accessor missing on ${rel} — wrong relation type or ORM version?`
|
|
2324
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
|
-
try {
|
|
2328
|
-
await writeAccessor.detach!([childId])
|
|
2329
|
-
} catch (err) {
|
|
2330
|
-
const message = err instanceof Error ? err.message : 'Detach failed'
|
|
2331
|
-
res.status(500)
|
|
2332
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
const listUrl = parentBase.replace(':id', pre.recordId)
|
|
2336
|
-
if (json) {
|
|
2337
|
-
const notifications = [
|
|
2338
|
-
{ id: `n-rdetach-${childId}-${Date.now()}`, type: 'success', title: `${M.getLabelSingular()} detached` },
|
|
2339
|
-
]
|
|
2340
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2341
|
-
}
|
|
2342
|
-
return res.redirect(listUrl, 303)
|
|
2343
|
-
})
|
|
2344
|
-
|
|
2345
|
-
// ── Phase B nested relation routes ──────────────────
|
|
2346
|
-
// For each manager N declared under M.relations(), mount the
|
|
2347
|
-
// depth-2 list/create/view/edit/delete handlers. Auth + chain
|
|
2348
|
-
// IDOR are centralized in `nestedRelationManagerData` — route
|
|
2349
|
-
// bodies dispatch the data builder and unwrap the tagged
|
|
2350
|
-
// {ok:false,status:403} / null shapes. Surface area mirrors
|
|
2351
|
-
// Phase A: no M2M attach/detach, no soft-delete restore on
|
|
2352
|
-
// nested managers in v1 (open follow-ups if a consumer asks).
|
|
2353
|
-
for (const N of M.relations()) {
|
|
2354
|
-
const nestedRel = N.getRelationship()
|
|
2355
|
-
const nestedBase = `${parentBase}/:childId/${nestedRel}`
|
|
2356
|
-
|
|
2357
|
-
// Build a `chain` tuple from the URL params for relayed calls
|
|
2358
|
-
// into `relationManagerData`. The childId of the *outer* manager
|
|
2359
|
-
// is the recordId of the leaf step.
|
|
2360
|
-
const buildChain = (id: string, childId1: string): [{ recordId: string; relationship: string }, { recordId: string; relationship: string }] => [
|
|
2361
|
-
{ recordId: id, relationship: rel },
|
|
2362
|
-
{ recordId: childId1, relationship: nestedRel },
|
|
2363
|
-
]
|
|
2364
|
-
|
|
2365
|
-
// ── List ──
|
|
2366
|
-
router.get(nestedBase, async (req, res) => {
|
|
2367
|
-
const json = wantsJson(req)
|
|
2368
|
-
const id = req.params['id']!
|
|
2369
|
-
const childId1 = req.params['childId']!
|
|
2370
|
-
const data = await relationManagerData(pilotiq, {
|
|
2371
|
-
kind: 'nested-relation-list', slug,
|
|
2372
|
-
chain: buildChain(id, childId1),
|
|
2373
|
-
query: req.query as Record<string, string>,
|
|
2374
|
-
}, req)
|
|
2375
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
2376
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
2377
|
-
return view('pilotiq.nested-relation-list', data)
|
|
2378
|
-
})
|
|
2379
|
-
|
|
2380
|
-
// ── Create (GET) ──
|
|
2381
|
-
router.get(`${nestedBase}/create`, async (req, res) => {
|
|
2382
|
-
const json = wantsJson(req)
|
|
2383
|
-
const id = req.params['id']!
|
|
2384
|
-
const childId1 = req.params['childId']!
|
|
2385
|
-
const data = await relationManagerData(pilotiq, {
|
|
2386
|
-
kind: 'nested-relation-create', slug,
|
|
2387
|
-
chain: buildChain(id, childId1),
|
|
2388
|
-
}, req)
|
|
2389
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
2390
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
2391
|
-
return view('pilotiq.nested-relation-create', data)
|
|
2392
|
-
})
|
|
2393
|
-
|
|
2394
|
-
// ── Create (POST) ──
|
|
2395
|
-
router.post(`${nestedBase}/create`, async (req, res) => {
|
|
2396
|
-
const json = wantsJson(req)
|
|
2397
|
-
const id = req.params['id']!
|
|
2398
|
-
const childId1 = req.params['childId']!
|
|
2399
|
-
// Run the chain walk once to verify auth + IDOR + load child1.
|
|
2400
|
-
// Any failure returns the same tagged shape we serve on GET.
|
|
2401
|
-
const pre = await relationManagerData(pilotiq, {
|
|
2402
|
-
kind: 'nested-relation-create', slug,
|
|
2403
|
-
chain: buildChain(id, childId1),
|
|
2404
|
-
}, req)
|
|
2405
|
-
if (pre === null) { res.status(404); return res.send('Not found') }
|
|
2406
|
-
if ('ok' in pre && pre.ok === false) return forbidden(res, json)
|
|
2407
|
-
|
|
2408
|
-
// Re-resolve the leaf manager's bits for form submit. We need
|
|
2409
|
-
// the leaf parent record (`child1`) and the related class for
|
|
2410
|
-
// save/loadRecord wiring. Reuse `findRelatedResource` against
|
|
2411
|
-
// the chain walk's intermediate Resource (Related1).
|
|
2412
|
-
const Related1 = findRelatedResource(M, R, cfg)
|
|
2413
|
-
if (!Related1) {
|
|
2414
|
-
res.status(500)
|
|
2415
|
-
const msg = `Nested manager ${N.name}: cannot resolve middle Resource for create`
|
|
2416
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2417
|
-
}
|
|
2418
|
-
const Related2 = findRelatedResource(N, Related1, cfg)
|
|
2419
|
-
if (!Related2?.model) {
|
|
2420
|
-
res.status(500)
|
|
2421
|
-
const msg = `Nested manager ${N.name}: cannot resolve related Resource for create`
|
|
2422
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2423
|
-
}
|
|
2424
|
-
const user = await pilotiq.resolveUser(req)
|
|
2425
|
-
const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
|
|
2426
|
-
if (!child1) { res.status(404); return res.send('Not found') }
|
|
2427
|
-
|
|
2428
|
-
const body = await readFormBody(req)
|
|
2429
|
-
const { values } = splitMeta(body)
|
|
2430
|
-
|
|
2431
|
-
const createUrl = `${nestedBase}/create`.replace(':id', id).replace(':childId', childId1)
|
|
2432
|
-
const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
|
|
2433
|
-
|
|
2434
|
-
const nestedMode: RelationMode = Related1.model
|
|
2435
|
-
? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
|
|
2436
|
-
: 'hasMany'
|
|
2437
|
-
|
|
2438
|
-
const form = N.form(Form.make(), {
|
|
2439
|
-
basePath: base,
|
|
2440
|
-
parentSlug: slug,
|
|
2441
|
-
parentId: childId1,
|
|
2442
|
-
relationship: nestedRel,
|
|
2443
|
-
parentRecord: child1,
|
|
2444
|
-
related: Related2,
|
|
2445
|
-
mode: nestedMode,
|
|
2446
|
-
chain: [{ slug, recordId: id, relationship: rel }],
|
|
2447
|
-
})
|
|
2448
|
-
if (Related2.model) {
|
|
2449
|
-
if (!form.getSave()) form.save(modelSave(Related2.model))
|
|
2450
|
-
if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
// Polymorphic morph-column auto-injection mirrors the depth-1
|
|
2454
|
-
// create handler — uses Related1 (the leaf parent's owner) as
|
|
2455
|
-
// the morph source on the leaf relation.
|
|
2456
|
-
if (nestedMode === 'morphMany' && Related1.model) {
|
|
2457
|
-
const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
|
|
2458
|
-
if (!morphDesc) {
|
|
2459
|
-
res.status(500)
|
|
2460
|
-
const msg = `Nested manager ${N.name}: relations[${JSON.stringify(nestedRel)}] reports a polymorphic type but is missing morphName.`
|
|
2461
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2462
|
-
}
|
|
2463
|
-
const morphPayload = computeMorphPayload(child1, morphDesc)
|
|
2464
|
-
const existing = form.getMutateDataBeforeCreate()
|
|
2465
|
-
form.mutateDataBeforeCreate(async (data, ctx) => {
|
|
2466
|
-
const next = existing ? await existing(data, ctx) : data
|
|
2467
|
-
return { ...next, ...morphPayload }
|
|
2468
|
-
})
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
const formCtx = {
|
|
2472
|
-
values,
|
|
2473
|
-
basePath: base,
|
|
2474
|
-
parent: child1,
|
|
2475
|
-
parentId: childId1,
|
|
2476
|
-
relationship: nestedRel,
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
const result = await dispatchFormSubmit(form, values, formCtx)
|
|
2480
|
-
if (!result.ok) {
|
|
2481
|
-
if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
|
|
2482
|
-
const data = await relationManagerData(pilotiq, {
|
|
2483
|
-
kind: 'nested-relation-create', slug,
|
|
2484
|
-
chain: buildChain(id, childId1),
|
|
2485
|
-
prefill: { values, errors: result.errors ?? {} },
|
|
2486
|
-
}, req)
|
|
2487
|
-
res.status(422)
|
|
2488
|
-
return view('pilotiq.nested-relation-create', data ?? {})
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
|
|
2492
|
-
if (json) {
|
|
2493
|
-
return res.json({
|
|
2494
|
-
ok: true, redirect,
|
|
2495
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
2496
|
-
})
|
|
2497
|
-
}
|
|
2498
|
-
flashNotifications(req, result.notifications)
|
|
2499
|
-
return res.redirect(redirect, 303)
|
|
2500
|
-
// `createUrl` referenced above is intentionally unused on
|
|
2501
|
-
// success — kept for parity with the depth-1 path's prefill
|
|
2502
|
-
// re-render shape if a future caller wants to redirect to it.
|
|
2503
|
-
void createUrl
|
|
2504
|
-
})
|
|
2505
|
-
|
|
2506
|
-
// ── View ──
|
|
2507
|
-
router.get(`${nestedBase}/:childId2`, async (req, res) => {
|
|
2508
|
-
const json = wantsJson(req)
|
|
2509
|
-
const id = req.params['id']!
|
|
2510
|
-
const childId1 = req.params['childId']!
|
|
2511
|
-
const childId2 = req.params['childId2']!
|
|
2512
|
-
if (childId2 === 'create') { res.status(404); return res.send('Not found') }
|
|
2513
|
-
const data = await relationManagerData(pilotiq, {
|
|
2514
|
-
kind: 'nested-relation-view', slug,
|
|
2515
|
-
chain: buildChain(id, childId1),
|
|
2516
|
-
childId: childId2,
|
|
2517
|
-
}, req)
|
|
2518
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
2519
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
2520
|
-
return view('pilotiq.nested-relation-view', data)
|
|
2521
|
-
})
|
|
2522
|
-
|
|
2523
|
-
// ── Edit (GET) ──
|
|
2524
|
-
router.get(`${nestedBase}/:childId2/edit`, async (req, res) => {
|
|
2525
|
-
const json = wantsJson(req)
|
|
2526
|
-
const id = req.params['id']!
|
|
2527
|
-
const childId1 = req.params['childId']!
|
|
2528
|
-
const childId2 = req.params['childId2']!
|
|
2529
|
-
const data = await relationManagerData(pilotiq, {
|
|
2530
|
-
kind: 'nested-relation-edit', slug,
|
|
2531
|
-
chain: buildChain(id, childId1),
|
|
2532
|
-
childId: childId2,
|
|
2533
|
-
}, req)
|
|
2534
|
-
if (data === null) { res.status(404); return res.send('Not found') }
|
|
2535
|
-
if ('ok' in data && data.ok === false) return forbidden(res, json)
|
|
2536
|
-
return view('pilotiq.nested-relation-edit', data)
|
|
2537
|
-
})
|
|
2538
|
-
|
|
2539
|
-
// ── Edit (POST) ──
|
|
2540
|
-
router.post(`${nestedBase}/:childId2/edit`, async (req, res) => {
|
|
2541
|
-
const json = wantsJson(req)
|
|
2542
|
-
const id = req.params['id']!
|
|
2543
|
-
const childId1 = req.params['childId']!
|
|
2544
|
-
const childId2 = req.params['childId2']!
|
|
2545
|
-
|
|
2546
|
-
// Replay the chain to verify auth, IDOR, load child1+child2.
|
|
2547
|
-
const pre = await relationManagerData(pilotiq, {
|
|
2548
|
-
kind: 'nested-relation-edit', slug,
|
|
2549
|
-
chain: buildChain(id, childId1),
|
|
2550
|
-
childId: childId2,
|
|
2551
|
-
}, req)
|
|
2552
|
-
if (pre === null) { res.status(404); return res.send('Not found') }
|
|
2553
|
-
if ('ok' in pre && pre.ok === false) return forbidden(res, json)
|
|
2554
|
-
|
|
2555
|
-
const Related1 = findRelatedResource(M, R, cfg)
|
|
2556
|
-
if (!Related1) {
|
|
2557
|
-
res.status(500)
|
|
2558
|
-
const msg = `Nested manager ${N.name}: cannot resolve middle Resource for edit`
|
|
2559
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2560
|
-
}
|
|
2561
|
-
const Related2 = findRelatedResource(N, Related1, cfg)
|
|
2562
|
-
if (!Related2?.model) {
|
|
2563
|
-
res.status(500)
|
|
2564
|
-
const msg = `Nested manager ${N.name}: cannot resolve related Resource for edit`
|
|
2565
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
const user = await pilotiq.resolveUser(req)
|
|
2569
|
-
const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
|
|
2570
|
-
if (!child1) { res.status(404); return res.send('Not found') }
|
|
2571
|
-
const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
|
|
2572
|
-
if (!child2) { res.status(404); return res.send('Not found') }
|
|
2573
|
-
|
|
2574
|
-
const body = await readFormBody(req)
|
|
2575
|
-
const { values } = splitMeta(body)
|
|
2576
|
-
|
|
2577
|
-
const editUrl = `${nestedBase}/${childId2}/edit`.replace(':id', id).replace(':childId', childId1)
|
|
2578
|
-
|
|
2579
|
-
const nestedMode: RelationMode = Related1.model
|
|
2580
|
-
? normalizeRelationMode(getRelationType(Related1.model, nestedRel))
|
|
2581
|
-
: 'hasMany'
|
|
2582
|
-
|
|
2583
|
-
const form = N.form(Form.make(), {
|
|
2584
|
-
basePath: base,
|
|
2585
|
-
parentSlug: slug,
|
|
2586
|
-
parentId: childId1,
|
|
2587
|
-
relationship: nestedRel,
|
|
2588
|
-
parentRecord: child1,
|
|
2589
|
-
related: Related2,
|
|
2590
|
-
mode: nestedMode,
|
|
2591
|
-
chain: [{ slug, recordId: id, relationship: rel }],
|
|
2592
|
-
})
|
|
2593
|
-
if (!form.getSave()) form.save(modelSave(Related2.model))
|
|
2594
|
-
if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related2))
|
|
2595
|
-
|
|
2596
|
-
if (nestedMode === 'morphMany' && Related1.model) {
|
|
2597
|
-
const morphDesc = getMorphRelationDescriptor(Related1.model, nestedRel)
|
|
2598
|
-
if (morphDesc) {
|
|
2599
|
-
const morphPayload = computeMorphPayload(child1, morphDesc)
|
|
2600
|
-
const existing = form.getMutateDataBeforeUpdate()
|
|
2601
|
-
form.mutateDataBeforeUpdate(async (data, ctx) => {
|
|
2602
|
-
const next = existing ? await existing(data, ctx) : data
|
|
2603
|
-
return { ...next, ...morphPayload }
|
|
2604
|
-
})
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
|
|
2608
|
-
const formCtx = {
|
|
2609
|
-
values,
|
|
2610
|
-
basePath: base,
|
|
2611
|
-
record: child2,
|
|
2612
|
-
parent: child1,
|
|
2613
|
-
parentId: childId1,
|
|
2614
|
-
relationship: nestedRel,
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
const result = await dispatchFormSubmit(form, values, formCtx)
|
|
2618
|
-
if (!result.ok) {
|
|
2619
|
-
if (json) { res.status(422); return res.json({ ok: false, errors: result.errors }) }
|
|
2620
|
-
const data = await relationManagerData(pilotiq, {
|
|
2621
|
-
kind: 'nested-relation-edit', slug,
|
|
2622
|
-
chain: buildChain(id, childId1),
|
|
2623
|
-
childId: childId2,
|
|
2624
|
-
prefill: { values, errors: result.errors ?? {} },
|
|
2625
|
-
}, req)
|
|
2626
|
-
res.status(422)
|
|
2627
|
-
return view('pilotiq.nested-relation-edit', data ?? {})
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
|
|
2631
|
-
if (json) {
|
|
2632
|
-
return res.json({
|
|
2633
|
-
ok: true, redirect,
|
|
2634
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
2635
|
-
})
|
|
2636
|
-
}
|
|
2637
|
-
flashNotifications(req, result.notifications)
|
|
2638
|
-
return res.redirect(redirect, 303)
|
|
2639
|
-
})
|
|
2640
|
-
|
|
2641
|
-
// ── Delete ──
|
|
2642
|
-
router.post(`${nestedBase}/:childId2/delete`, async (req, res) => {
|
|
2643
|
-
const json = wantsJson(req)
|
|
2644
|
-
const id = req.params['id']!
|
|
2645
|
-
const childId1 = req.params['childId']!
|
|
2646
|
-
const childId2 = req.params['childId2']!
|
|
2647
|
-
|
|
2648
|
-
// Replay the chain to verify auth + IDOR + load child2.
|
|
2649
|
-
// We piggy-back on the edit scope's checks (canEdit on the
|
|
2650
|
-
// leaf manager — same gate the depth-1 delete uses today via
|
|
2651
|
-
// the relation-edit scope).
|
|
2652
|
-
const pre = await relationManagerData(pilotiq, {
|
|
2653
|
-
kind: 'nested-relation-edit', slug,
|
|
2654
|
-
chain: buildChain(id, childId1),
|
|
2655
|
-
childId: childId2,
|
|
2656
|
-
}, req)
|
|
2657
|
-
if (pre === null) { res.status(404); return res.send('Not found') }
|
|
2658
|
-
if ('ok' in pre && pre.ok === false) return forbidden(res, json)
|
|
2659
|
-
|
|
2660
|
-
const Related1 = findRelatedResource(M, R, cfg)
|
|
2661
|
-
if (!Related1) {
|
|
2662
|
-
res.status(500)
|
|
2663
|
-
const msg = `Nested manager ${N.name}: cannot resolve middle Resource for delete`
|
|
2664
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2665
|
-
}
|
|
2666
|
-
const Related2 = findRelatedResource(N, Related1, cfg)
|
|
2667
|
-
if (!Related2?.model) {
|
|
2668
|
-
res.status(500)
|
|
2669
|
-
const msg = `Nested manager ${N.name}: cannot resolve related Resource for delete`
|
|
2670
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
const user = await pilotiq.resolveUser(req)
|
|
2674
|
-
const child1 = await findRecord(Related1, childId1, { user }).catch(() => undefined)
|
|
2675
|
-
if (!child1) { res.status(404); return res.send('Not found') }
|
|
2676
|
-
const child2 = await findRecord(Related2, childId2, { user }).catch(() => undefined)
|
|
2677
|
-
if (!child2) { res.status(404); return res.send('Not found') }
|
|
2678
|
-
|
|
2679
|
-
if (!await safeManagerPolicy(N, 'canDelete', Related2, user, child1, child2)) return forbidden(res, json)
|
|
2680
|
-
|
|
2681
|
-
const listUrl = nestedBase.replace(':id', id).replace(':childId', childId1)
|
|
2682
|
-
try {
|
|
2683
|
-
await Related2.model.delete(childId2)
|
|
2684
|
-
} catch (err) {
|
|
2685
|
-
const message = err instanceof Error ? err.message : 'Delete failed'
|
|
2686
|
-
res.status(500)
|
|
2687
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2688
|
-
}
|
|
2689
|
-
|
|
2690
|
-
if (json) {
|
|
2691
|
-
const notifications = [
|
|
2692
|
-
{ id: `n-nrdelete-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} deleted` },
|
|
2693
|
-
]
|
|
2694
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2695
|
-
}
|
|
2696
|
-
return res.redirect(listUrl, 303)
|
|
2697
|
-
})
|
|
2698
|
-
|
|
2699
|
-
// ── Phase B follow-up — nested action / detach / soft-delete ──
|
|
2700
|
-
// Mirror the depth-1 manager surface (`_action`, `_detach`,
|
|
2701
|
-
// `restore`, `force-delete`) under the nested manager. Auth +
|
|
2702
|
-
// chain IDOR centralized in `resolveRelationChain`; each route
|
|
2703
|
-
// layers its own scope-specific gate (canDetach / canRestore /
|
|
2704
|
-
// canForceDelete; the action route mirrors depth-1 by not adding
|
|
2705
|
-
// an extra manager-level gate beyond the chain walk).
|
|
2706
|
-
const nestedChainSlug = slug
|
|
2707
|
-
const requireNestedChain = async (req: AppRequest, res: AppResponse, json: boolean): Promise<{
|
|
2708
|
-
user: unknown
|
|
2709
|
-
resolved: ResolvedChain
|
|
2710
|
-
parentId: string
|
|
2711
|
-
child1Id: string
|
|
2712
|
-
} | undefined> => {
|
|
2713
|
-
const id = req.params['id']!
|
|
2714
|
-
const child1Id = req.params['childId']!
|
|
2715
|
-
const user = await pilotiq.resolveUser(req)
|
|
2716
|
-
const resolved = await resolveRelationChain(pilotiq, {
|
|
2717
|
-
kind: 'nested-relation-list',
|
|
2718
|
-
slug: nestedChainSlug,
|
|
2719
|
-
chain: [
|
|
2720
|
-
{ recordId: id, relationship: rel },
|
|
2721
|
-
{ recordId: child1Id, relationship: nestedRel },
|
|
2722
|
-
],
|
|
2723
|
-
}, user)
|
|
2724
|
-
if (resolved === null) { res.status(404); res.send('Not found'); return undefined }
|
|
2725
|
-
if ('ok' in resolved) { forbidden(res, json); return undefined }
|
|
2726
|
-
return { user, resolved, parentId: id, child1Id }
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
// Listing URL (filled per request — `:id` / `:childId` get baked
|
|
2730
|
-
// in once the params are known). All four routes redirect here
|
|
2731
|
-
// on success so users land back on the nested-relation list.
|
|
2732
|
-
const nestedListUrlFor = (id: string, child1Id: string): string =>
|
|
2733
|
-
nestedBase.replace(':id', id).replace(':childId', child1Id)
|
|
2734
|
-
|
|
2735
|
-
// ── Action dispatch — POST ${nestedBase}/_action/:actionName ──
|
|
2736
|
-
// Resolves N's table elements, finds the named action, dispatches
|
|
2737
|
-
// it with `ctx.relation = { parent: child1, parentId, rel }` so
|
|
2738
|
-
// M2M handlers on the nested manager can call accessor methods.
|
|
2739
|
-
// Handler-style actions are useful on hasMany too — mounted
|
|
2740
|
-
// unconditionally.
|
|
2741
|
-
router.post(`${nestedBase}/_action/:actionName`, async (req, res) => {
|
|
2742
|
-
const json = wantsJson(req)
|
|
2743
|
-
const pre = await requireNestedChain(req, res, json)
|
|
2744
|
-
if (!pre) return
|
|
2745
|
-
const { resolved } = pre
|
|
2746
|
-
const { Related1, child1, M2, Related2, child2Mode } = resolved
|
|
2747
|
-
|
|
2748
|
-
const actionName = req.params['actionName']!
|
|
2749
|
-
const body = await readFormBody(req)
|
|
2750
|
-
const input = parseActionBody(body)
|
|
2751
|
-
|
|
2752
|
-
// Manager ctx for N — same shape `nestedManagerCtx` builds for
|
|
2753
|
-
// the data-builder side, so factories that close over `ctx`
|
|
2754
|
-
// (URL templates, mode-aware visibility) see the same view as
|
|
2755
|
-
// at page render.
|
|
2756
|
-
const nestedManagerCtxObj = {
|
|
2757
|
-
basePath: base,
|
|
2758
|
-
parentSlug: resolved.R.getSlug(),
|
|
2759
|
-
parentId: pre.child1Id, // immediate parent of N = child1
|
|
2760
|
-
relationship: nestedRel,
|
|
2761
|
-
parentRecord: child1,
|
|
2762
|
-
related: Related2,
|
|
2763
|
-
mode: child2Mode,
|
|
2764
|
-
chain: [{
|
|
2765
|
-
slug: resolved.R.getSlug(),
|
|
2766
|
-
recordId: pre.parentId,
|
|
2767
|
-
relationship: rel,
|
|
2768
|
-
}],
|
|
2769
|
-
}
|
|
2770
|
-
const table = M2.table(Table.make(), nestedManagerCtxObj)
|
|
2771
|
-
const elements: import('./schema/Element.js').Element[] = [table]
|
|
2772
|
-
const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
|
|
2773
|
-
tagActionDispatch(elements, listUrl)
|
|
2774
|
-
|
|
2775
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
2776
|
-
if (!target) {
|
|
2777
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
2778
|
-
res.status(404)
|
|
2779
|
-
return res.send(`Action "${actionName}" not found on ${M2.name}`)
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
const resolveRecord: ResolveRecord | undefined = Related2?.model
|
|
2783
|
-
? (id: string) => Related2.model!.find(id)
|
|
2784
|
-
: undefined
|
|
2785
|
-
|
|
2786
|
-
const result = await dispatchAction(target.action, {
|
|
2787
|
-
...input,
|
|
2788
|
-
request: req,
|
|
2789
|
-
user: pre.user,
|
|
2790
|
-
relation: { parent: child1, parentId: pre.child1Id, relationship: nestedRel },
|
|
2791
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
2792
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
2793
|
-
}, resolveRecord)
|
|
2794
|
-
|
|
2795
|
-
if (!result.ok) {
|
|
2796
|
-
if (json) {
|
|
2797
|
-
res.status(result.errors ? 422 : 500)
|
|
2798
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
2799
|
-
}
|
|
2800
|
-
res.status(500)
|
|
2801
|
-
return res.send(result.error)
|
|
2802
|
-
}
|
|
2803
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? listUrl
|
|
2804
|
-
if (json) {
|
|
2805
|
-
return res.json({
|
|
2806
|
-
ok: true,
|
|
2807
|
-
redirect,
|
|
2808
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
2809
|
-
})
|
|
2810
|
-
}
|
|
2811
|
-
flashNotifications(req, result.notifications)
|
|
2812
|
-
return res.redirect(redirect, 303)
|
|
2813
|
-
})
|
|
2814
|
-
|
|
2815
|
-
// ── Detach — POST ${nestedBase}/:childId2/_detach ──
|
|
2816
|
-
// M2M-only direct row-detach. IDOR-checks the grandchild against
|
|
2817
|
-
// child1.related(nestedRel), then calls accessor.detach. Mirrors
|
|
2818
|
-
// the depth-1 detach route at line 1955.
|
|
2819
|
-
router.post(`${nestedBase}/:childId2/_detach`, async (req, res) => {
|
|
2820
|
-
const json = wantsJson(req)
|
|
2821
|
-
const pre = await requireNestedChain(req, res, json)
|
|
2822
|
-
if (!pre) return
|
|
2823
|
-
const childId2 = req.params['childId2']!
|
|
2824
|
-
const { resolved } = pre
|
|
2825
|
-
const { Related1, child1, M2, Related2, child2Mode } = resolved
|
|
2826
|
-
|
|
2827
|
-
if (child2Mode !== 'belongsToMany' && child2Mode !== 'morphToMany' && child2Mode !== 'morphedByMany') {
|
|
2828
|
-
res.status(404)
|
|
2829
|
-
const msg = 'Detach is only supported on M2M relations (belongsToMany, morphToMany, morphedByMany)'
|
|
2830
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2831
|
-
}
|
|
2832
|
-
|
|
2833
|
-
// IDOR: confirm child2 is currently attached to child1 under
|
|
2834
|
-
// nestedRel. Read-side accessor (`child1.related(nestedRel)`)
|
|
2835
|
-
// returns a deferred QueryBuilder; we never bypass it.
|
|
2836
|
-
const readSide = (child1 as { related?: (n: string) => { where?: (...a: unknown[]) => unknown; paginate?: (p: number, pp: number) => Promise<{ data: unknown[] }> } })
|
|
2837
|
-
?.related?.(nestedRel)
|
|
2838
|
-
if (!readSide) {
|
|
2839
|
-
res.status(500)
|
|
2840
|
-
const msg = `child1.related("${nestedRel}") missing — wrong relation type or ORM version?`
|
|
2841
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2842
|
-
}
|
|
2843
|
-
let child2: unknown = undefined
|
|
2844
|
-
try {
|
|
2845
|
-
if (typeof readSide.paginate === 'function') {
|
|
2846
|
-
const pk = Related2?.model ? getPrimaryKey(Related2.model) : 'id'
|
|
2847
|
-
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)
|
|
2848
|
-
child2 = Array.isArray(out.data) ? out.data[0] : undefined
|
|
2849
|
-
}
|
|
2850
|
-
} catch { /* fall through */ }
|
|
2851
|
-
if (child2 === undefined) { res.status(404); return res.send('Not found') }
|
|
2852
|
-
|
|
2853
|
-
if (!await safeManagerPolicy(M2, 'canDetach', Related2, pre.user, child1, child2)) return forbidden(res, json)
|
|
2854
|
-
|
|
2855
|
-
// Real ORM: child1[nestedRel]() returns the pivot accessor
|
|
2856
|
-
// with attach/detach/sync. Test stubs may collapse onto
|
|
2857
|
-
// `child1.related(nestedRel)` — try both.
|
|
2858
|
-
let writeAccessor: { detach?: (ids: unknown) => Promise<unknown> } | undefined
|
|
2859
|
-
const inst = (child1 as Record<string, unknown>)[nestedRel]
|
|
2860
|
-
if (typeof inst === 'function') {
|
|
2861
|
-
try {
|
|
2862
|
-
const out = (inst as () => unknown).call(child1) as { detach?: (ids: unknown) => Promise<unknown> } | undefined
|
|
2863
|
-
if (out && typeof out.detach === 'function') writeAccessor = out
|
|
2864
|
-
} catch { /* fall through */ }
|
|
2865
|
-
}
|
|
2866
|
-
if (!writeAccessor && typeof (readSide as { detach?: unknown }).detach === 'function') {
|
|
2867
|
-
writeAccessor = readSide as { detach: (ids: unknown) => Promise<unknown> }
|
|
2868
|
-
}
|
|
2869
|
-
if (!writeAccessor) {
|
|
2870
|
-
res.status(500)
|
|
2871
|
-
const msg = `Pivot accessor missing on ${nestedRel} — wrong relation type or ORM version?`
|
|
2872
|
-
return json ? res.json({ ok: false, error: msg }) : res.send(msg)
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
try {
|
|
2876
|
-
await writeAccessor.detach!([childId2])
|
|
2877
|
-
} catch (err) {
|
|
2878
|
-
const message = err instanceof Error ? err.message : 'Detach failed'
|
|
2879
|
-
res.status(500)
|
|
2880
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2881
|
-
}
|
|
2882
|
-
|
|
2883
|
-
const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
|
|
2884
|
-
if (json) {
|
|
2885
|
-
const notifications = [
|
|
2886
|
-
{ id: `n-nrdetach-${childId2}-${Date.now()}`, type: 'success', title: `${M2.getLabelSingular()} detached` },
|
|
2887
|
-
]
|
|
2888
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2889
|
-
}
|
|
2890
|
-
return res.redirect(listUrl, 303)
|
|
2891
|
-
})
|
|
2892
|
-
|
|
2893
|
-
// ── Soft-delete: restore + force-delete ───────────────────────
|
|
2894
|
-
// Opt in only when Related2 has `softDeletes = true` AND its
|
|
2895
|
-
// model carries `restore` / `forceDelete`. Mirrors the depth-1
|
|
2896
|
-
// routes at line 1804+. IDOR runs against child1.related(nestedRel)
|
|
2897
|
-
// broadened with `withTrashed()` so trashed grandchildren resolve.
|
|
2898
|
-
const Related1ForSoft = findRelatedResource(M, R, cfg)
|
|
2899
|
-
const Related2ForSoft = Related1ForSoft ? findRelatedResource(N, Related1ForSoft, cfg) : undefined
|
|
2900
|
-
if (Related2ForSoft?.softDeletes) {
|
|
2901
|
-
const RM2 = Related2ForSoft.model
|
|
2902
|
-
if (!RM2) {
|
|
2903
|
-
throw new Error(
|
|
2904
|
-
`[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but no model. ` +
|
|
2905
|
-
`Wire one up or unset softDeletes.`,
|
|
2906
|
-
)
|
|
2907
|
-
}
|
|
2908
|
-
if (typeof RM2.restore !== 'function' || typeof RM2.forceDelete !== 'function') {
|
|
2909
|
-
throw new Error(
|
|
2910
|
-
`[Pilotiq] Nested RelationManager ${N.name} on ${M.name} (${R.name}): related Resource ${Related2ForSoft.name} has softDeletes = true but model.restore / model.forceDelete are missing. ` +
|
|
2911
|
-
`Set Model.softDeletes = true on the rudder side, or upgrade @rudderjs/orm.`,
|
|
2912
|
-
)
|
|
2913
|
-
}
|
|
2914
|
-
|
|
2915
|
-
// Like the depth-1 helper: load the grandchild via the parent's
|
|
2916
|
-
// relation query, broadened with `withTrashed()`. Returns
|
|
2917
|
-
// undefined when the lookup misses or the grandchild doesn't
|
|
2918
|
-
// belong to child1 under nestedRel.
|
|
2919
|
-
const loadTrashableGrandchild = async (parentChild: unknown, child2Id: string): Promise<unknown> => {
|
|
2920
|
-
const pk = (RM2.primaryKey ?? 'id') as string
|
|
2921
|
-
try {
|
|
2922
|
-
const q: import('./orm/modelDefaults.js').ModelQuery = (parentChild as { related: (n: string) => import('./orm/modelDefaults.js').ModelQuery }).related(nestedRel)
|
|
2923
|
-
const broadened = typeof q.withTrashed === 'function' ? q.withTrashed() : q
|
|
2924
|
-
const result = await broadened.where(pk, '=', child2Id).paginate(1, 1)
|
|
2925
|
-
return Array.isArray(result.data) ? result.data[0] : undefined
|
|
2926
|
-
} catch {
|
|
2927
|
-
return undefined
|
|
2928
|
-
}
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
// Restore — POST ${nestedBase}/:childId2/restore
|
|
2932
|
-
router.post(`${nestedBase}/:childId2/restore`, async (req, res) => {
|
|
2933
|
-
const json = wantsJson(req)
|
|
2934
|
-
const pre = await requireNestedChain(req, res, json)
|
|
2935
|
-
if (!pre) return
|
|
2936
|
-
const childId2 = req.params['childId2']!
|
|
2937
|
-
const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
|
|
2938
|
-
if (!child2) { res.status(404); return res.send('Not found') }
|
|
2939
|
-
|
|
2940
|
-
if (!await safeManagerPolicy(N, 'canRestore', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
|
|
2941
|
-
|
|
2942
|
-
const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
|
|
2943
|
-
try {
|
|
2944
|
-
await RM2.restore!(childId2)
|
|
2945
|
-
} catch (err) {
|
|
2946
|
-
const message = err instanceof Error ? err.message : 'Restore failed'
|
|
2947
|
-
res.status(500)
|
|
2948
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
if (json) {
|
|
2952
|
-
const notifications = [
|
|
2953
|
-
{ id: `n-nrrestore-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} restored` },
|
|
2954
|
-
]
|
|
2955
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2956
|
-
}
|
|
2957
|
-
return res.redirect(listUrl, 303)
|
|
2958
|
-
})
|
|
2959
|
-
|
|
2960
|
-
// Force-delete — POST ${nestedBase}/:childId2/force-delete
|
|
2961
|
-
router.post(`${nestedBase}/:childId2/force-delete`, async (req, res) => {
|
|
2962
|
-
const json = wantsJson(req)
|
|
2963
|
-
const pre = await requireNestedChain(req, res, json)
|
|
2964
|
-
if (!pre) return
|
|
2965
|
-
const childId2 = req.params['childId2']!
|
|
2966
|
-
const child2 = await loadTrashableGrandchild(pre.resolved.child1, childId2)
|
|
2967
|
-
if (!child2) { res.status(404); return res.send('Not found') }
|
|
2968
|
-
|
|
2969
|
-
if (!await safeManagerPolicy(N, 'canForceDelete', Related2ForSoft, pre.user, pre.resolved.child1, child2)) return forbidden(res, json)
|
|
2970
|
-
|
|
2971
|
-
const listUrl = nestedListUrlFor(pre.parentId, pre.child1Id)
|
|
2972
|
-
try {
|
|
2973
|
-
await RM2.forceDelete!(childId2)
|
|
2974
|
-
} catch (err) {
|
|
2975
|
-
const message = err instanceof Error ? err.message : 'Force-delete failed'
|
|
2976
|
-
res.status(500)
|
|
2977
|
-
return json ? res.json({ ok: false, error: message }) : res.send(message)
|
|
2978
|
-
}
|
|
2979
|
-
|
|
2980
|
-
if (json) {
|
|
2981
|
-
const notifications = [
|
|
2982
|
-
{ id: `n-nrforce-${childId2}-${Date.now()}`, type: 'success', title: `${N.getLabelSingular()} permanently deleted` },
|
|
2983
|
-
]
|
|
2984
|
-
return res.json({ ok: true, redirect: listUrl, notifications })
|
|
2985
|
-
}
|
|
2986
|
-
return res.redirect(listUrl, 303)
|
|
2987
|
-
})
|
|
2988
|
-
}
|
|
2989
212
|
}
|
|
213
|
+
editableEnabled.add(R.getSlug())
|
|
2990
214
|
}
|
|
215
|
+
}
|
|
2991
216
|
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
})
|
|
3010
|
-
}
|
|
217
|
+
// ── Panel-level sibling routes ────────────────────────
|
|
218
|
+
// Dashboard, _uploads, _widget, _search, _notifications.
|
|
219
|
+
// Pulled out 2026-05-12 (Phase 2 of the routes.ts split).
|
|
220
|
+
registerPanelRoutes(router, pilotiq, base)
|
|
221
|
+
|
|
222
|
+
// ── Resource routes ───────────────────────────────────
|
|
223
|
+
// List / view / create / edit / delete + soft-delete / actions /
|
|
224
|
+
// widgets / deferred-table / reorder / per-row editable cells / the
|
|
225
|
+
// four form-state companion endpoints / record sub-pages. Each
|
|
226
|
+
// Resource also fans out into its registered relation managers
|
|
227
|
+
// (depth-1 + depth-2). Pulled out 2026-05-12 (Phase 5 of the
|
|
228
|
+
// routes.ts split).
|
|
229
|
+
for (const R of cfg.resources) {
|
|
230
|
+
registerResourceRoutes(router, pilotiq, R, base, {
|
|
231
|
+
reorderable: reorderEnabled.has(R.getSlug()),
|
|
232
|
+
editable: editableEnabled.has(R.getSlug()),
|
|
233
|
+
})
|
|
3011
234
|
}
|
|
3012
235
|
|
|
3013
236
|
// ── Globals (singletons — 2-segment, no /:id) ────────
|
|
237
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
3014
238
|
for (const G of cfg.globals) {
|
|
3015
|
-
|
|
3016
|
-
const editUrl = globalBasePath(base, G)
|
|
3017
|
-
const pages = G.resolvePages()
|
|
3018
|
-
|
|
3019
|
-
if (pages.edit) {
|
|
3020
|
-
const PageClass = pages.edit
|
|
3021
|
-
|
|
3022
|
-
// Plan #5 partial-resolve endpoint for the global's edit form.
|
|
3023
|
-
// POST ${editUrl}/_form/:formId/state
|
|
3024
|
-
router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
|
|
3025
|
-
const user = await pilotiq.resolveUser(req)
|
|
3026
|
-
if (!await policyAccess(G, user)) return forbidden(res, true)
|
|
3027
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
3028
|
-
const formId = req.params['formId']!
|
|
3029
|
-
return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
3030
|
-
})
|
|
3031
|
-
|
|
3032
|
-
// Plan #8 wizard step-validate endpoint for the global's edit form.
|
|
3033
|
-
router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
|
|
3034
|
-
const user = await pilotiq.resolveUser(req)
|
|
3035
|
-
if (!await policyAccess(G, user)) return forbidden(res, true)
|
|
3036
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
3037
|
-
const formId = req.params['formId']!
|
|
3038
|
-
return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
3039
|
-
})
|
|
3040
|
-
|
|
3041
|
-
// Async-mention endpoint for the global's edit form.
|
|
3042
|
-
router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
|
|
3043
|
-
const user = await pilotiq.resolveUser(req)
|
|
3044
|
-
if (!await policyAccess(G, user)) return forbidden(res, true)
|
|
3045
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
3046
|
-
const formId = req.params['formId']!
|
|
3047
|
-
return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
3048
|
-
})
|
|
3049
|
-
|
|
3050
|
-
// SelectField inline-create modal endpoint for the global's edit form.
|
|
3051
|
-
router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
3052
|
-
const user = await pilotiq.resolveUser(req)
|
|
3053
|
-
if (!await policyAccess(G, user)) return forbidden(res, true)
|
|
3054
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
3055
|
-
const formId = req.params['formId']!
|
|
3056
|
-
const fieldName = req.params['fieldName']!
|
|
3057
|
-
return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
|
|
3058
|
-
})
|
|
3059
|
-
|
|
3060
|
-
router.get(editUrl, async (req, res) => {
|
|
3061
|
-
const user = await pilotiq.resolveUser(req)
|
|
3062
|
-
if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
|
|
3063
|
-
// Globals carry their record on the singleton form's `loadRecord`;
|
|
3064
|
-
// we don't pre-load here — pass a stub so canEdit's signature is
|
|
3065
|
-
// honored, and let user code decide whether to consult it.
|
|
3066
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
|
|
3067
|
-
const data = await globalEditData(pilotiq, slug, undefined, req)
|
|
3068
|
-
return view('pilotiq.slug', data ?? {})
|
|
3069
|
-
})
|
|
3070
|
-
|
|
3071
|
-
router.post(editUrl, async (req, res) => {
|
|
3072
|
-
const body = await readFormBody(req)
|
|
3073
|
-
const { values, formId } = splitMeta(body)
|
|
3074
|
-
const json = wantsJson(req)
|
|
3075
|
-
|
|
3076
|
-
const user = await pilotiq.resolveUser(req)
|
|
3077
|
-
if (!await policyAccess(G, user)) return forbidden(res, json)
|
|
3078
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
|
|
3079
|
-
|
|
3080
|
-
const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
3081
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
3082
|
-
tagFormActions(elements, editUrl)
|
|
3083
|
-
const form = selectForm(findForms(elements), formId)
|
|
3084
|
-
if (!form) {
|
|
3085
|
-
if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
|
|
3086
|
-
res.status(404)
|
|
3087
|
-
return res.send('No form found on page')
|
|
3088
|
-
}
|
|
3089
|
-
|
|
3090
|
-
// Provide the existing singleton record to the lifecycle context
|
|
3091
|
-
// so cross-field validators / mutateData see prior state.
|
|
3092
|
-
let record: unknown = undefined
|
|
3093
|
-
if (form.getLoadRecord()) {
|
|
3094
|
-
try { record = await form.getLoadRecord()!('', { values }) } catch { /* ignore */ }
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
const result = await dispatchFormSubmit(
|
|
3098
|
-
form,
|
|
3099
|
-
values,
|
|
3100
|
-
record !== undefined ? { values, record, basePath: base } : { values, basePath: base },
|
|
3101
|
-
)
|
|
3102
|
-
|
|
3103
|
-
if (!result.ok) {
|
|
3104
|
-
if (json) {
|
|
3105
|
-
res.status(422)
|
|
3106
|
-
return res.json({ ok: false, errors: result.errors })
|
|
3107
|
-
}
|
|
3108
|
-
const data = await globalEditData(pilotiq, slug, { values, errors: result.errors })
|
|
3109
|
-
res.status(422)
|
|
3110
|
-
return view('pilotiq.slug', data ?? {})
|
|
3111
|
-
}
|
|
3112
|
-
|
|
3113
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? editUrl
|
|
3114
|
-
if (json) {
|
|
3115
|
-
return res.json({
|
|
3116
|
-
ok: true,
|
|
3117
|
-
redirect,
|
|
3118
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
3119
|
-
})
|
|
3120
|
-
}
|
|
3121
|
-
flashNotifications(req, result.notifications)
|
|
3122
|
-
return res.redirect(redirect, 303)
|
|
3123
|
-
})
|
|
3124
|
-
}
|
|
3125
|
-
|
|
3126
|
-
// Optional view page when the user opts in via pages().view
|
|
3127
|
-
if (pages.view) {
|
|
3128
|
-
router.get(`${editUrl}/view`, async (req, res) => {
|
|
3129
|
-
const user = await pilotiq.resolveUser(req)
|
|
3130
|
-
if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
|
|
3131
|
-
if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
|
|
3132
|
-
const data = await globalViewData(pilotiq, slug, req)
|
|
3133
|
-
return view('pilotiq.resource-view', data ?? {})
|
|
3134
|
-
})
|
|
3135
|
-
}
|
|
239
|
+
registerGlobalRoutes(router, pilotiq, G, base)
|
|
3136
240
|
}
|
|
3137
241
|
|
|
3138
242
|
// ── Custom pages (2-segment, slug route) ──────────────
|
|
243
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
3139
244
|
for (const PageClass of cfg.pages) {
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
//
|
|
3143
|
-
// `slug = ''`).
|
|
245
|
+
// The dashboard page lives at `${base}` (panel routes handle it);
|
|
246
|
+
// skip it here so we don't register a duplicate `${pageUrl}` route
|
|
247
|
+
// or a broken `${base}/` (when `slug = ''`).
|
|
3144
248
|
if (cfg.dashboardPage === PageClass) continue
|
|
3145
|
-
|
|
3146
|
-
const pageSlug = PageClass.getSlug()
|
|
3147
|
-
const pageUrl = pageBasePath(base, PageClass)
|
|
3148
|
-
|
|
3149
|
-
// Plan #15 — per-page widget polling endpoint. Mirrors the
|
|
3150
|
-
// panel-scope `${base}/_widget/:id` but resolves the custom page's
|
|
3151
|
-
// schema instead of the dashboard's.
|
|
3152
|
-
router.post(`${pageUrl}/_widget/:id`, async (req, res) => {
|
|
3153
|
-
const user = await pilotiq.resolveUser(req)
|
|
3154
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, true)
|
|
3155
|
-
return handleWidgetData(req, res, pilotiq, { kind: 'page', pageSlug }, req.params['id']!)
|
|
3156
|
-
})
|
|
3157
|
-
|
|
3158
|
-
// Plan #5 partial-resolve endpoint for custom pages with reactive forms.
|
|
3159
|
-
// POST ${base}/${pageSlug}/_form/:formId/state
|
|
3160
|
-
router.post(`${pageUrl}/_form/:formId/state`, async (req, res) => {
|
|
3161
|
-
const user = await pilotiq.resolveUser(req)
|
|
3162
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, true)
|
|
3163
|
-
const formId = req.params['formId']!
|
|
3164
|
-
return handleFormState(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
|
|
3165
|
-
})
|
|
3166
|
-
|
|
3167
|
-
// Plan #8 wizard step-validate endpoint for custom pages.
|
|
3168
|
-
router.post(`${pageUrl}/_form/:formId/wizard`, async (req, res) => {
|
|
3169
|
-
const user = await pilotiq.resolveUser(req)
|
|
3170
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, true)
|
|
3171
|
-
const formId = req.params['formId']!
|
|
3172
|
-
return handleFormWizard(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
|
|
3173
|
-
})
|
|
3174
|
-
|
|
3175
|
-
// Async-mention endpoint for custom pages.
|
|
3176
|
-
router.post(`${pageUrl}/_form/:formId/mentions`, async (req, res) => {
|
|
3177
|
-
const user = await pilotiq.resolveUser(req)
|
|
3178
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, true)
|
|
3179
|
-
const formId = req.params['formId']!
|
|
3180
|
-
return handleFormMentions(req, res, pilotiq, { kind: 'page', pageSlug }, formId)
|
|
3181
|
-
})
|
|
3182
|
-
|
|
3183
|
-
// SelectField inline-create modal endpoint for custom pages.
|
|
3184
|
-
router.post(`${pageUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
3185
|
-
const user = await pilotiq.resolveUser(req)
|
|
3186
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, true)
|
|
3187
|
-
const formId = req.params['formId']!
|
|
3188
|
-
const fieldName = req.params['fieldName']!
|
|
3189
|
-
return handleFormCreateOption(req, res, pilotiq, { kind: 'page', pageSlug }, formId, fieldName)
|
|
3190
|
-
})
|
|
3191
|
-
|
|
3192
|
-
router.get(pageUrl, async (req, res) => {
|
|
3193
|
-
const user = await pilotiq.resolveUser(req)
|
|
3194
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
|
|
3195
|
-
const data = await customPageData(pilotiq, pageSlug, req)
|
|
3196
|
-
return view('pilotiq.slug', data ?? {})
|
|
3197
|
-
})
|
|
3198
|
-
|
|
3199
|
-
// Action dispatch — POST ${base}/${pageSlug}/_action/:actionName
|
|
3200
|
-
router.post(`${pageUrl}/_action/:actionName`, async (req, res) => {
|
|
3201
|
-
const user = await pilotiq.resolveUser(req)
|
|
3202
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, wantsJson(req))
|
|
3203
|
-
|
|
3204
|
-
const actionName = req.params['actionName']!
|
|
3205
|
-
const json = wantsJson(req)
|
|
3206
|
-
const body = await readFormBody(req)
|
|
3207
|
-
const input = parseActionBody(body)
|
|
3208
|
-
|
|
3209
|
-
const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
|
|
3210
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
3211
|
-
tagActionDispatch(elements, pageUrl)
|
|
3212
|
-
const target = resolveDispatchTarget(elements, actionName)
|
|
3213
|
-
if (!target) {
|
|
3214
|
-
if (json) { res.status(404); return res.json({ ok: false, error: `Action "${actionName}" not found` }) }
|
|
3215
|
-
res.status(404)
|
|
3216
|
-
return res.send(`Action "${actionName}" not found on page`)
|
|
3217
|
-
}
|
|
3218
|
-
|
|
3219
|
-
const result = await dispatchAction(target.action, {
|
|
3220
|
-
...input,
|
|
3221
|
-
request: req,
|
|
3222
|
-
user,
|
|
3223
|
-
...(target.rowField ? { rowField: target.rowField } : {}),
|
|
3224
|
-
...(target.formSchema ? { formSchema: target.formSchema } : {}),
|
|
3225
|
-
})
|
|
3226
|
-
if (!result.ok) {
|
|
3227
|
-
if (json) {
|
|
3228
|
-
res.status(result.errors ? 422 : 500)
|
|
3229
|
-
return res.json({ ok: false, error: result.error, ...(result.errors ? { errors: result.errors } : {}) })
|
|
3230
|
-
}
|
|
3231
|
-
res.status(500)
|
|
3232
|
-
return res.send(result.error)
|
|
3233
|
-
}
|
|
3234
|
-
if (result.download) return sendDownload(res, result.download)
|
|
3235
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
|
|
3236
|
-
if (json) {
|
|
3237
|
-
return res.json({
|
|
3238
|
-
ok: true,
|
|
3239
|
-
redirect,
|
|
3240
|
-
...(result.notifications ? { notifications: result.notifications } : {}),
|
|
3241
|
-
})
|
|
3242
|
-
}
|
|
3243
|
-
flashNotifications(req, result.notifications)
|
|
3244
|
-
return res.redirect(redirect, 303)
|
|
3245
|
-
})
|
|
3246
|
-
|
|
3247
|
-
// Custom pages can also accept submits when their schema includes a Form.
|
|
3248
|
-
router.post(pageUrl, async (req, res) => {
|
|
3249
|
-
const body = await readFormBody(req)
|
|
3250
|
-
const { values, formId } = splitMeta(body)
|
|
3251
|
-
const json = wantsJson(req)
|
|
3252
|
-
|
|
3253
|
-
const user = await pilotiq.resolveUser(req)
|
|
3254
|
-
if (!await policyAccess(PageClass, user)) return forbidden(res, json)
|
|
3255
|
-
|
|
3256
|
-
const ctx: SchemaContext = user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}
|
|
3257
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
3258
|
-
tagFormActions(elements, pageUrl)
|
|
3259
|
-
const form = selectForm(findForms(elements), formId)
|
|
3260
|
-
if (!form) {
|
|
3261
|
-
if (json) { res.status(404); return res.json({ ok: false, error: 'No form found on page' }) }
|
|
3262
|
-
res.status(404)
|
|
3263
|
-
return res.send('No form found on page')
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
const result = await dispatchFormSubmit(form, values, { values, basePath: base })
|
|
3267
|
-
|
|
3268
|
-
if (!result.ok) {
|
|
3269
|
-
if (json) {
|
|
3270
|
-
res.status(422)
|
|
3271
|
-
return res.json({ ok: false, errors: result.errors })
|
|
3272
|
-
}
|
|
3273
|
-
form.withValues(values).withErrors(result.errors)
|
|
3274
|
-
const schemaData = await resolveSchema(elements, ctx)
|
|
3275
|
-
res.status(422)
|
|
3276
|
-
return view('pilotiq.slug', {
|
|
3277
|
-
pageType: 'page',
|
|
3278
|
-
panel: await panelInfo(pilotiq, req),
|
|
3279
|
-
page: PageClass.toMeta(),
|
|
3280
|
-
schemaData,
|
|
3281
|
-
basePath: base,
|
|
3282
|
-
layout: cfg.layout,
|
|
3283
|
-
hasErrors: true,
|
|
3284
|
-
})
|
|
3285
|
-
}
|
|
3286
|
-
|
|
3287
|
-
const redirect = normalizeRedirect(result.redirect, base) ?? pageUrl
|
|
3288
|
-
if (json) {
|
|
3289
|
-
return res.json({
|
|
3290
|
-
ok: true,
|
|
3291
|
-
redirect,
|
|
3292
|
-
...(result.notifications && result.notifications.length > 0 ? { notifications: result.notifications } : {}),
|
|
3293
|
-
})
|
|
3294
|
-
}
|
|
3295
|
-
flashNotifications(req, result.notifications)
|
|
3296
|
-
return res.redirect(redirect, 303)
|
|
3297
|
-
})
|
|
249
|
+
registerCustomPageRoutes(router, pilotiq, PageClass, base)
|
|
3298
250
|
}
|
|
3299
251
|
|
|
3300
252
|
// ── Theme editor ──────────────────────────────────────
|
|
253
|
+
// Pulled out 2026-05-12 (Phase 3 of the routes.ts split).
|
|
3301
254
|
if (cfg.themeEditor) {
|
|
3302
|
-
router
|
|
3303
|
-
return view('pilotiq.theme', {
|
|
3304
|
-
panel: await panelInfo(pilotiq, req),
|
|
3305
|
-
basePath: base,
|
|
3306
|
-
layout: cfg.layout,
|
|
3307
|
-
themeConfig: pilotiq.getMergedTheme() ?? {},
|
|
3308
|
-
})
|
|
3309
|
-
})
|
|
3310
|
-
|
|
3311
|
-
router.get(`${base}/api/_theme`, async (_req, res) => {
|
|
3312
|
-
let overrides: Partial<ThemeConfig> | null = null
|
|
3313
|
-
try {
|
|
3314
|
-
const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
|
|
3315
|
-
const prisma = app().make('prisma') as any
|
|
3316
|
-
const slug = `${cfg.name}__theme`
|
|
3317
|
-
const row = await prisma.panelGlobal.findUnique({ where: { slug } })
|
|
3318
|
-
if (row?.data) {
|
|
3319
|
-
const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
|
|
3320
|
-
overrides = migrateThemeOverrides(raw)
|
|
3321
|
-
}
|
|
3322
|
-
} catch { /* no DB or no table — that's fine */ }
|
|
3323
|
-
|
|
3324
|
-
return res.json({
|
|
3325
|
-
config: cfg.theme ?? {},
|
|
3326
|
-
overrides: overrides ?? {},
|
|
3327
|
-
options: {
|
|
3328
|
-
presets: Object.keys(presets),
|
|
3329
|
-
baseColors: Object.keys(baseColors),
|
|
3330
|
-
themeColors: ['base', ...HUE_NAMES],
|
|
3331
|
-
chartColors: ['base', ...HUE_NAMES],
|
|
3332
|
-
radii: Object.keys(radiusMap),
|
|
3333
|
-
iconLibraries: ['lucide', 'tabler', 'phosphor', 'remix'],
|
|
3334
|
-
},
|
|
3335
|
-
})
|
|
3336
|
-
})
|
|
3337
|
-
|
|
3338
|
-
router.put(`${base}/api/_theme`, async (req, res) => {
|
|
3339
|
-
try {
|
|
3340
|
-
const overrides = req.body as Partial<ThemeConfig>
|
|
3341
|
-
const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
|
|
3342
|
-
const prisma = app().make('prisma') as any
|
|
3343
|
-
const slug = `${cfg.name}__theme`
|
|
3344
|
-
|
|
3345
|
-
await prisma.panelGlobal.upsert({
|
|
3346
|
-
where: { slug },
|
|
3347
|
-
update: { data: JSON.stringify(overrides) },
|
|
3348
|
-
create: { slug, data: JSON.stringify(overrides) },
|
|
3349
|
-
})
|
|
3350
|
-
|
|
3351
|
-
pilotiq.setThemeOverrides(overrides)
|
|
3352
|
-
return res.json({ ok: true })
|
|
3353
|
-
} catch (e) {
|
|
3354
|
-
return res.status(500).json({ message: e instanceof Error ? e.message : 'Failed to save theme' })
|
|
3355
|
-
}
|
|
3356
|
-
})
|
|
3357
|
-
|
|
3358
|
-
router.delete(`${base}/api/_theme`, async (_req, res) => {
|
|
3359
|
-
try {
|
|
3360
|
-
const { app } = await import(/* @vite-ignore */ '@rudderjs/core') as { app(): { make(key: string): unknown } }
|
|
3361
|
-
const prisma = app().make('prisma') as any
|
|
3362
|
-
const slug = `${cfg.name}__theme`
|
|
3363
|
-
await prisma.panelGlobal.delete({ where: { slug } }).catch(() => {})
|
|
3364
|
-
pilotiq.setThemeOverrides(undefined)
|
|
3365
|
-
} catch { /* ignore */ }
|
|
3366
|
-
return res.json({ ok: true })
|
|
3367
|
-
})
|
|
255
|
+
registerThemeRoutes(router, pilotiq, base)
|
|
3368
256
|
}
|
|
3369
257
|
|
|
3370
258
|
// Plugin route hook — runs AFTER all core routes register so plugins
|