opacacms 0.1.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/bun.lock +34 -0
- package/dist/admin/api-client.d.ts +8 -0
- package/dist/admin/auth-client.d.ts +940 -0
- package/dist/admin/custom-field.d.ts +71 -0
- package/dist/admin/index.d.ts +11 -0
- package/dist/admin/react.d.ts +3 -0
- package/dist/admin/router.d.ts +7 -0
- package/dist/admin/stores/admin-queries.d.ts +32 -0
- package/dist/admin/stores/auth.d.ts +33 -0
- package/dist/admin/stores/column-visibility.d.ts +21 -0
- package/dist/admin/stores/config.d.ts +7 -0
- package/dist/admin/stores/media.d.ts +44 -0
- package/dist/admin/stores/query.d.ts +4 -0
- package/dist/admin/stores/ui.d.ts +11 -0
- package/dist/admin/ui/admin-client.d.ts +7 -0
- package/dist/admin/ui/admin-layout.d.ts +14 -0
- package/dist/admin/ui/components/ColumnVisibilityToggle.d.ts +10 -0
- package/dist/admin/ui/components/DataDetailSheet.d.ts +13 -0
- package/dist/admin/ui/components/DataDetailView.d.ts +9 -0
- package/dist/admin/ui/components/Table.d.ts +10 -0
- package/dist/admin/ui/components/fields/ArrayField.d.ts +13 -0
- package/dist/admin/ui/components/fields/BlocksField.d.ts +17 -0
- package/dist/admin/ui/components/fields/BooleanField.d.ts +13 -0
- package/dist/admin/ui/components/fields/CollapsibleField.d.ts +16 -0
- package/dist/admin/ui/components/fields/DateField.d.ts +13 -0
- package/dist/admin/ui/components/fields/FileField.d.ts +23 -0
- package/dist/admin/ui/components/fields/GroupField.d.ts +13 -0
- package/dist/admin/ui/components/fields/JoinField.d.ts +15 -0
- package/dist/admin/ui/components/fields/NumberField.d.ts +14 -0
- package/dist/admin/ui/components/fields/RadioField.d.ts +17 -0
- package/dist/admin/ui/components/fields/RelationshipField.d.ts +16 -0
- package/dist/admin/ui/components/fields/RowField.d.ts +12 -0
- package/dist/admin/ui/components/fields/SelectField.d.ts +18 -0
- package/dist/admin/ui/components/fields/TabsField.d.ts +15 -0
- package/dist/admin/ui/components/fields/TextAreaField.d.ts +14 -0
- package/dist/admin/ui/components/fields/TextField.d.ts +14 -0
- package/dist/admin/ui/components/fields/VirtualField.d.ts +8 -0
- package/dist/admin/ui/components/fields/index.d.ts +28 -0
- package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +10 -0
- package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.d.ts +7 -0
- package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageNode.d.ts +27 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.d.ts +5 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.d.ts +1 -0
- package/dist/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.d.ts +5 -0
- package/dist/admin/ui/components/fields/utils.d.ts +1 -0
- package/dist/admin/ui/components/link.d.ts +8 -0
- package/dist/admin/ui/components/media/AssetManagerModal.d.ts +17 -0
- package/dist/admin/ui/components/toast.d.ts +10 -0
- package/dist/admin/ui/components/ui/accordion.d.ts +11 -0
- package/dist/admin/ui/components/ui/alert-dialog.d.ts +12 -0
- package/dist/admin/ui/components/ui/blocks.d.ts +5 -0
- package/dist/admin/ui/components/ui/breadcrumbs.d.ts +7 -0
- package/dist/admin/ui/components/ui/button.d.ts +7 -0
- package/dist/admin/ui/components/ui/collapsible.d.ts +16 -0
- package/dist/admin/ui/components/ui/dialog.d.ts +27 -0
- package/dist/admin/ui/components/ui/group.d.ts +6 -0
- package/dist/admin/ui/components/ui/index.d.ts +17 -0
- package/dist/admin/ui/components/ui/input.d.ts +5 -0
- package/dist/admin/ui/components/ui/join.d.ts +7 -0
- package/dist/admin/ui/components/ui/label.d.ts +3 -0
- package/dist/admin/ui/components/ui/radio-group.d.ts +13 -0
- package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +9 -0
- package/dist/admin/ui/components/ui/relationship.d.ts +8 -0
- package/dist/admin/ui/components/ui/scroll-area.d.ts +7 -0
- package/dist/admin/ui/components/ui/select.d.ts +37 -0
- package/dist/admin/ui/components/ui/separator.d.ts +8 -0
- package/dist/admin/ui/components/ui/sheet.d.ts +28 -0
- package/dist/admin/ui/components/ui/tabs.d.ts +17 -0
- package/dist/admin/ui/components/ui/utils.d.ts +1 -0
- package/dist/admin/ui/hooks/use-debounce.d.ts +1 -0
- package/dist/admin/ui/views/collection-list-view.d.ts +5 -0
- package/dist/admin/ui/views/dashboard-view.d.ts +10 -0
- package/dist/admin/ui/views/document-edit-view.d.ts +7 -0
- package/dist/admin/ui/views/global-edit-view.d.ts +19 -0
- package/dist/admin/ui/views/init-view.d.ts +4 -0
- package/dist/admin/ui/views/login-view.d.ts +4 -0
- package/dist/admin/ui/views/media-registry-view.d.ts +7 -0
- package/dist/admin/ui/views/settings-view.d.ts +7 -0
- package/dist/admin/webcomponent.d.ts +1 -0
- package/dist/api.d.ts +6 -0
- package/dist/auth/index.d.ts +2107 -0
- package/dist/auth/migrations.d.ts +5 -0
- package/dist/auth/premissions.d.ts +6 -0
- package/dist/chunk-16vgcf3k.js +88 -0
- package/dist/chunk-2zm8cy1w.js +9482 -0
- package/dist/chunk-5gvbp2qa.js +167 -0
- package/dist/chunk-62ev8gnc.js +41 -0
- package/dist/chunk-6dhs73zq.js +126 -0
- package/dist/chunk-6ew02s0c.js +472 -0
- package/dist/chunk-7a9kn0np.js +116 -0
- package/dist/chunk-8gkhn1d4.js +309 -0
- package/dist/chunk-8sqjbsgt.js +42 -0
- package/dist/chunk-9kxpbcb1.js +85 -0
- package/dist/chunk-cvdd4eqh.js +110 -0
- package/dist/chunk-d3ffeqp9.js +87 -0
- package/dist/chunk-dy5t83hr.js +261 -0
- package/dist/chunk-f3nvxn63.js +17 -0
- package/dist/chunk-hmhcense.js +1352 -0
- package/dist/chunk-j4d50hrx.js +20 -0
- package/dist/chunk-jwjk85ze.js +15 -0
- package/dist/chunk-kwp83w8b.js +250 -0
- package/dist/chunk-s8mqwnm1.js +14 -0
- package/dist/chunk-srsac177.js +85 -0
- package/dist/chunk-v521d72w.js +10 -0
- package/dist/chunk-xa7rjsn2.js +20 -0
- package/dist/chunk-xg35h5a3.js +15 -0
- package/dist/chunk-ybbbqj63.js +130 -0
- package/dist/chunk-zvwb67nd.js +332 -0
- package/dist/cli/commands/generate-types.d.ts +1 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/migrate-commands.d.ts +5 -0
- package/dist/cli/commands/seed-command.d.ts +2 -0
- package/dist/cli/d1-mock.d.ts +30 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.test.d.ts +1 -0
- package/dist/cli/r2-mock.d.ts +46 -0
- package/dist/cli/seeding.d.ts +17 -0
- package/dist/client.d.ts +51 -0
- package/dist/config-utils.d.ts +6 -0
- package/dist/config.d.ts +10 -0
- package/dist/db/adapter.d.ts +34 -0
- package/dist/db/better-sqlite.d.ts +40 -0
- package/dist/db/bun-sqlite.d.ts +40 -0
- package/dist/db/d1.d.ts +42 -0
- package/dist/db/kysely/data-mapper.d.ts +6 -0
- package/dist/db/kysely/field-mapper.d.ts +22 -0
- package/dist/db/kysely/migration-generator.d.ts +9 -0
- package/dist/db/kysely/query-builder.d.ts +9 -0
- package/dist/db/kysely/schema-builder.d.ts +15 -0
- package/dist/db/kysely/sql-utils.d.ts +1 -0
- package/dist/db/postgres.d.ts +51 -0
- package/dist/db/sqlite.d.ts +41 -0
- package/dist/db/system-schema.d.ts +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/runtimes/bun.d.ts +17 -0
- package/dist/runtimes/cloudflare-workers.d.ts +10 -0
- package/dist/runtimes/next.d.ts +16 -0
- package/dist/runtimes/node.d.ts +18 -0
- package/dist/schema/collection.d.ts +100 -0
- package/dist/schema/fields/base.d.ts +83 -0
- package/dist/schema/fields/index.d.ts +135 -0
- package/dist/schema/global.d.ts +82 -0
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/infer.d.ts +55 -0
- package/dist/server/admin-router.d.ts +9 -0
- package/dist/server/admin.d.ts +18 -0
- package/dist/server/assets.d.ts +47 -0
- package/dist/server/collection-router.d.ts +14 -0
- package/dist/server/handlers.d.ts +76 -0
- package/dist/server/middlewares/admin.d.ts +6 -0
- package/dist/server/middlewares/auth.d.ts +16 -0
- package/dist/server/middlewares/context.d.ts +9 -0
- package/dist/server/middlewares/cors.d.ts +3 -0
- package/dist/server/middlewares/database-init.d.ts +11 -0
- package/dist/server/middlewares/rate-limit.d.ts +3 -0
- package/dist/server/router.d.ts +7 -0
- package/dist/server/setup-middlewares.d.ts +17 -0
- package/dist/server/system-router.d.ts +9 -0
- package/dist/server.d.ts +6 -0
- package/dist/src/admin/index.css +47 -0
- package/dist/src/admin/index.js +176 -0
- package/dist/src/admin/webcomponent.js +19 -0
- package/dist/src/api.js +27 -0
- package/dist/src/cli/index.js +157 -0
- package/dist/src/client.js +9 -0
- package/dist/src/db/bun-sqlite.js +523 -0
- package/dist/src/db/d1.js +568 -0
- package/dist/src/db/postgres.js +520 -0
- package/dist/src/db/sqlite.js +534 -0
- package/dist/src/index.js +20 -0
- package/dist/src/runtimes/bun.js +36 -0
- package/dist/src/runtimes/cloudflare-workers.js +29 -0
- package/dist/src/runtimes/next.js +26 -0
- package/dist/src/runtimes/node.js +38 -0
- package/dist/src/server.js +27 -0
- package/dist/src/storage/index.js +355 -0
- package/dist/storage/adapters/cloudflare-r2.d.ts +6 -0
- package/dist/storage/adapters/local.d.ts +6 -0
- package/dist/storage/adapters/s3.d.ts +13 -0
- package/dist/storage/errors.d.ts +12 -0
- package/dist/storage/index.d.ts +5 -0
- package/dist/storage/types.d.ts +31 -0
- package/dist/types.d.ts +484 -0
- package/dist/utils/lexical.d.ts +5 -0
- package/dist/utils/logger.d.ts +35 -0
- package/dist/validation.d.ts +300 -0
- package/dist/validator.d.ts +9 -0
- package/global.d.ts +11 -0
- package/package.json +151 -0
- package/src/admin/api-client.ts +63 -0
- package/src/admin/auth-client.ts +40 -0
- package/src/admin/custom-field.ts +179 -0
- package/src/admin/index.ts +15 -0
- package/src/admin/react.tsx +72 -0
- package/src/admin/router.ts +9 -0
- package/src/admin/stores/admin-queries.ts +121 -0
- package/src/admin/stores/auth.ts +61 -0
- package/src/admin/stores/column-visibility.ts +67 -0
- package/src/admin/stores/config.ts +15 -0
- package/src/admin/stores/media.ts +95 -0
- package/src/admin/stores/query.ts +13 -0
- package/src/admin/stores/ui.ts +29 -0
- package/src/admin/ui/admin-client.tsx +283 -0
- package/src/admin/ui/admin-layout.tsx +276 -0
- package/src/admin/ui/components/ColumnVisibilityToggle.tsx +141 -0
- package/src/admin/ui/components/DataDetailSheet.tsx +141 -0
- package/src/admin/ui/components/DataDetailView.tsx +175 -0
- package/src/admin/ui/components/Table.tsx +67 -0
- package/src/admin/ui/components/fields/ArrayField.tsx +166 -0
- package/src/admin/ui/components/fields/BlocksField.tsx +202 -0
- package/src/admin/ui/components/fields/BooleanField.tsx +50 -0
- package/src/admin/ui/components/fields/CollapsibleField.tsx +75 -0
- package/src/admin/ui/components/fields/DateField.tsx +45 -0
- package/src/admin/ui/components/fields/FileField.tsx +322 -0
- package/src/admin/ui/components/fields/GroupField.tsx +50 -0
- package/src/admin/ui/components/fields/JoinField.tsx +23 -0
- package/src/admin/ui/components/fields/NumberField.tsx +46 -0
- package/src/admin/ui/components/fields/RadioField.tsx +62 -0
- package/src/admin/ui/components/fields/RelationshipField.tsx +278 -0
- package/src/admin/ui/components/fields/RowField.tsx +40 -0
- package/src/admin/ui/components/fields/SelectField.tsx +59 -0
- package/src/admin/ui/components/fields/TabsField.tsx +101 -0
- package/src/admin/ui/components/fields/TextAreaField.tsx +54 -0
- package/src/admin/ui/components/fields/TextField.tsx +49 -0
- package/src/admin/ui/components/fields/VirtualField.tsx +53 -0
- package/src/admin/ui/components/fields/index.tsx +371 -0
- package/src/admin/ui/components/fields/richtext-editor/index.tsx +211 -0
- package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +142 -0
- package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +95 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +226 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +16 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +184 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +240 -0
- package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +40 -0
- package/src/admin/ui/components/fields/utils.ts +1 -0
- package/src/admin/ui/components/link.tsx +41 -0
- package/src/admin/ui/components/media/AssetManagerModal.tsx +334 -0
- package/src/admin/ui/components/toast.tsx +72 -0
- package/src/admin/ui/components/ui/accordion.tsx +51 -0
- package/src/admin/ui/components/ui/alert-dialog.tsx +98 -0
- package/src/admin/ui/components/ui/blocks.tsx +32 -0
- package/src/admin/ui/components/ui/breadcrumbs.tsx +59 -0
- package/src/admin/ui/components/ui/button.tsx +26 -0
- package/src/admin/ui/components/ui/collapsible.tsx +124 -0
- package/src/admin/ui/components/ui/dialog.tsx +79 -0
- package/src/admin/ui/components/ui/group.tsx +20 -0
- package/src/admin/ui/components/ui/index.ts +17 -0
- package/src/admin/ui/components/ui/input.tsx +12 -0
- package/src/admin/ui/components/ui/join.tsx +53 -0
- package/src/admin/ui/components/ui/label.tsx +11 -0
- package/src/admin/ui/components/ui/radio-group.tsx +75 -0
- package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +122 -0
- package/src/admin/ui/components/ui/relationship.tsx +58 -0
- package/src/admin/ui/components/ui/scroll-area.tsx +19 -0
- package/src/admin/ui/components/ui/select.tsx +187 -0
- package/src/admin/ui/components/ui/separator.tsx +21 -0
- package/src/admin/ui/components/ui/sheet.tsx +106 -0
- package/src/admin/ui/components/ui/tabs.tsx +116 -0
- package/src/admin/ui/components/ui/utils.ts +3 -0
- package/src/admin/ui/hooks/use-debounce.ts +15 -0
- package/src/admin/ui/styles/_locale-switcher.scss +33 -0
- package/src/admin/ui/styles/accordion.scss +60 -0
- package/src/admin/ui/styles/animations.scss +41 -0
- package/src/admin/ui/styles/asset-manager.scss +547 -0
- package/src/admin/ui/styles/badge.scss +13 -0
- package/src/admin/ui/styles/base.scss +22 -0
- package/src/admin/ui/styles/button.scss +161 -0
- package/src/admin/ui/styles/card.scss +13 -0
- package/src/admin/ui/styles/collapsible.scss +75 -0
- package/src/admin/ui/styles/data-detail.scss +92 -0
- package/src/admin/ui/styles/dialog.scss +102 -0
- package/src/admin/ui/styles/empty-state.scss +22 -0
- package/src/admin/ui/styles/group.scss +19 -0
- package/src/admin/ui/styles/index.scss +33 -0
- package/src/admin/ui/styles/input.scss +80 -0
- package/src/admin/ui/styles/label.scss +12 -0
- package/src/admin/ui/styles/layout.scss +56 -0
- package/src/admin/ui/styles/lexical.scss +469 -0
- package/src/admin/ui/styles/loading.scss +102 -0
- package/src/admin/ui/styles/media-registry.scss +597 -0
- package/src/admin/ui/styles/pagination.scss +20 -0
- package/src/admin/ui/styles/radio-group.scss +66 -0
- package/src/admin/ui/styles/row.scss +17 -0
- package/src/admin/ui/styles/scrollbar.scss +36 -0
- package/src/admin/ui/styles/select.scss +121 -0
- package/src/admin/ui/styles/separator.scss +14 -0
- package/src/admin/ui/styles/sheet.scss +152 -0
- package/src/admin/ui/styles/sidebar.scss +148 -0
- package/src/admin/ui/styles/switch.scss +59 -0
- package/src/admin/ui/styles/table.scss +207 -0
- package/src/admin/ui/styles/tabs.scss +62 -0
- package/src/admin/ui/styles/toast.scss +45 -0
- package/src/admin/ui/styles/variables.scss +24 -0
- package/src/admin/ui/views/collection-list-view.tsx +720 -0
- package/src/admin/ui/views/dashboard-view.tsx +263 -0
- package/src/admin/ui/views/document-edit-view.tsx +384 -0
- package/src/admin/ui/views/global-edit-view.tsx +226 -0
- package/src/admin/ui/views/init-view.tsx +182 -0
- package/src/admin/ui/views/login-view.tsx +123 -0
- package/src/admin/ui/views/media-registry-view.tsx +1104 -0
- package/src/admin/ui/views/settings-view.tsx +729 -0
- package/src/admin/webcomponent.tsx +15 -0
- package/src/api.ts +9 -0
- package/src/auth/index.ts +194 -0
- package/src/auth/migrations.ts +87 -0
- package/src/auth/premissions.ts +46 -0
- package/src/cli/commands/generate-types.ts +116 -0
- package/src/cli/commands/init.ts +95 -0
- package/src/cli/commands/migrate-commands.ts +160 -0
- package/src/cli/commands/seed-command.ts +11 -0
- package/src/cli/d1-mock.ts +101 -0
- package/src/cli/index.test.ts +84 -0
- package/src/cli/index.ts +183 -0
- package/src/cli/r2-mock.ts +217 -0
- package/src/cli/seeding.ts +405 -0
- package/src/client.ts +181 -0
- package/src/config-utils.ts +102 -0
- package/src/config.ts +49 -0
- package/src/db/adapter.ts +53 -0
- package/src/db/better-sqlite.ts +630 -0
- package/src/db/bun-sqlite.ts +646 -0
- package/src/db/d1.ts +711 -0
- package/src/db/kysely/data-mapper.ts +142 -0
- package/src/db/kysely/field-mapper.ts +148 -0
- package/src/db/kysely/migration-generator.ts +223 -0
- package/src/db/kysely/query-builder.ts +92 -0
- package/src/db/kysely/schema-builder.ts +439 -0
- package/src/db/kysely/sql-utils.ts +13 -0
- package/src/db/postgres.ts +621 -0
- package/src/db/sqlite.ts +658 -0
- package/src/db/system-schema.ts +121 -0
- package/src/index.ts +13 -0
- package/src/runtimes/README.md +59 -0
- package/src/runtimes/bun.ts +49 -0
- package/src/runtimes/cloudflare-workers.ts +38 -0
- package/src/runtimes/next.ts +26 -0
- package/src/runtimes/node.ts +52 -0
- package/src/schema/collection.ts +184 -0
- package/src/schema/fields/base.ts +164 -0
- package/src/schema/fields/index.ts +427 -0
- package/src/schema/global.ts +145 -0
- package/src/schema/index.ts +4 -0
- package/src/schema/infer.ts +72 -0
- package/src/server/admin-router.ts +20 -0
- package/src/server/admin.ts +142 -0
- package/src/server/assets.ts +306 -0
- package/src/server/collection-router.ts +55 -0
- package/src/server/handlers.ts +722 -0
- package/src/server/middlewares/admin.ts +27 -0
- package/src/server/middlewares/auth.ts +89 -0
- package/src/server/middlewares/context.ts +17 -0
- package/src/server/middlewares/cors.ts +24 -0
- package/src/server/middlewares/database-init.ts +74 -0
- package/src/server/middlewares/rate-limit.ts +71 -0
- package/src/server/router.ts +47 -0
- package/src/server/setup-middlewares.ts +58 -0
- package/src/server/system-router.ts +35 -0
- package/src/server.ts +9 -0
- package/src/storage/adapters/cloudflare-r2.ts +136 -0
- package/src/storage/adapters/local.ts +146 -0
- package/src/storage/adapters/s3.ts +186 -0
- package/src/storage/errors.ts +46 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/types.ts +39 -0
- package/src/types.ts +577 -0
- package/src/utils/lexical.ts +37 -0
- package/src/utils/logger.ts +73 -0
- package/src/validation.ts +429 -0
- package/src/validator.ts +179 -0
- package/test/admin-custom-field.test.ts +162 -0
- package/test/admin-react-field.test.tsx +134 -0
- package/test/api-features.test.ts +78 -0
- package/test/api.test.ts +178 -0
- package/test/auth.test.ts +62 -0
- package/test/cli-integration.test.ts +146 -0
- package/test/cli.test.ts +25 -0
- package/test/db/postgres.test.ts +95 -0
- package/test/db/sqlite-filter.test.ts +53 -0
- package/test/db/sqlite.test.ts +82 -0
- package/test/engine-features.test.ts +79 -0
- package/test/globals.test.ts +74 -0
- package/test/integration-tmp/db-app/opacacms.config.ts +15 -0
- package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +25 -0
- package/test/integration-tmp/my-test-app/index.ts +8 -0
- package/test/integration-tmp/my-test-app/opacacms.config.ts +16 -0
- package/test/integration-tmp/my-test-app/package.json +12 -0
- package/test/populate.test.ts +79 -0
- package/test/runtimes.test.ts +43 -0
- package/test/schema-builder.test.ts +107 -0
- package/test/schema-features.test.ts +63 -0
- package/test/seeding.test.ts +68 -0
- package/test/storage/local.test.ts +72 -0
- package/test/storage/s3.test.ts +60 -0
- package/test/structural-data.test.ts +100 -0
- package/test/test-setup.ts +11 -0
- package/test/validation.test.ts +162 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type { Collection, Global, OpacaConfig } from "../types";
|
|
3
|
+
import { logger } from "../utils/logger";
|
|
4
|
+
import { generateSchemaForCollection } from "../validator";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hydrates a document with virtual fields and nested fields.
|
|
8
|
+
* @param doc document to hydrate
|
|
9
|
+
* @param fields fields to hydrate
|
|
10
|
+
* @param c context
|
|
11
|
+
* @returns hydrated document
|
|
12
|
+
*/
|
|
13
|
+
export const hydrateDoc = async (doc: any, fields: any[], c: Context, config: OpacaConfig) => {
|
|
14
|
+
if (!doc) return doc;
|
|
15
|
+
|
|
16
|
+
const user = c.get("user");
|
|
17
|
+
const session = c.get("session");
|
|
18
|
+
const apiKey = c.get("apiKey");
|
|
19
|
+
|
|
20
|
+
const hydratePromises = fields.map(async (field) => {
|
|
21
|
+
// 1. Resolve Virtual/Computed Fields
|
|
22
|
+
if (field.type === "virtual" && typeof field.resolve === "function") {
|
|
23
|
+
try {
|
|
24
|
+
doc[field.name] = await field.resolve({
|
|
25
|
+
data: doc,
|
|
26
|
+
req: c,
|
|
27
|
+
user,
|
|
28
|
+
session,
|
|
29
|
+
apiKey,
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error(`[OpacaCMS] Failed to resolve virtual field ${field.name} `, err);
|
|
33
|
+
doc[field.name] = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Handle Nested Layout Fields (Recursive)
|
|
38
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
39
|
+
await hydrateDoc(doc, field.fields, c, config);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Handle Tabs (Recursive)
|
|
43
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
44
|
+
for (const tab of field.tabs) {
|
|
45
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
46
|
+
await hydrateDoc(doc, tab.fields, c, config);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. Handle Groups (Recursive - value stays in place unless explicitly mapped)
|
|
52
|
+
if (field.type === "group" && field.fields && doc[field.name]) {
|
|
53
|
+
await hydrateDoc(doc[field.name], field.fields, c, config);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 5. Handle Arrays (Recursive for each item)
|
|
57
|
+
if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
|
|
58
|
+
await Promise.all(
|
|
59
|
+
doc[field.name].map((item: any) => hydrateDoc(item, field.fields, c, config)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 6. Handle Blocks (Recursive for each block type)
|
|
64
|
+
if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
|
|
65
|
+
await Promise.all(
|
|
66
|
+
doc[field.name].map((item: any) => {
|
|
67
|
+
const block = field.blocks.find((b: any) => b.slug === item.block_type);
|
|
68
|
+
if (block && block.fields) {
|
|
69
|
+
return hydrateDoc(item, block.fields, c, config);
|
|
70
|
+
}
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// 7. Handle Localized Fields
|
|
76
|
+
if (field.localized && doc[field.name] && typeof doc[field.name] === "object") {
|
|
77
|
+
const i18nConfig = config.i18n;
|
|
78
|
+
const requestedLocale = c.req.header("x-opaca-locale") || c.req.query("locale");
|
|
79
|
+
|
|
80
|
+
// Resolve target locale: explicit request > defaultLocale > fallback to "en"
|
|
81
|
+
const targetLocale = requestedLocale || i18nConfig?.defaultLocale || "en";
|
|
82
|
+
|
|
83
|
+
if (targetLocale !== "all") {
|
|
84
|
+
const localizedValue =
|
|
85
|
+
doc[field.name][targetLocale] ?? doc[field.name][i18nConfig?.defaultLocale || "en"] ?? "";
|
|
86
|
+
doc[field.name] = localizedValue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await Promise.all(hydratePromises);
|
|
92
|
+
return doc;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Populates a document with related documents.
|
|
97
|
+
* This version is generic and takes the DB adapter and fields list.
|
|
98
|
+
*/
|
|
99
|
+
const populateDoc = async (db: any, fields: any[], doc: any, populateKeys: string[]) => {
|
|
100
|
+
if (!doc) return doc;
|
|
101
|
+
|
|
102
|
+
const populatePromises = fields
|
|
103
|
+
.filter((f) => {
|
|
104
|
+
const field = f as any;
|
|
105
|
+
return (
|
|
106
|
+
field.type === "relationship" &&
|
|
107
|
+
field.relationTo &&
|
|
108
|
+
populateKeys.includes(field.name) &&
|
|
109
|
+
doc[field.name]
|
|
110
|
+
);
|
|
111
|
+
})
|
|
112
|
+
.map(async (f) => {
|
|
113
|
+
const field = f as any;
|
|
114
|
+
try {
|
|
115
|
+
const relatedDoc = await db.findOne(field.relationTo, { id: doc[field.name] });
|
|
116
|
+
if (relatedDoc) {
|
|
117
|
+
doc[field.name] = relatedDoc;
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(`[OpacaCMS] Failed to populate relationship ${field.name} `, err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await Promise.all(populatePromises);
|
|
125
|
+
return doc;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Creates handlers for a collection
|
|
130
|
+
* @param config opaca config
|
|
131
|
+
* @param collection collection config
|
|
132
|
+
* @param getAuth auth function
|
|
133
|
+
* @returns collection handlers
|
|
134
|
+
*/
|
|
135
|
+
export function createHandlers(config: OpacaConfig, collection: Collection, getAuth?: () => any) {
|
|
136
|
+
const { db } = config;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Checks access to a collection
|
|
140
|
+
* @param c context
|
|
141
|
+
* @param action action to check
|
|
142
|
+
* @param data data to check
|
|
143
|
+
* @returns true if access is allowed, false otherwise
|
|
144
|
+
*/
|
|
145
|
+
const checkAccess = async (
|
|
146
|
+
c: Context,
|
|
147
|
+
action: keyof NonNullable<Collection["access"]>,
|
|
148
|
+
data?: unknown,
|
|
149
|
+
) => {
|
|
150
|
+
const access = collection.access?.[action];
|
|
151
|
+
|
|
152
|
+
// 1. Check API Key Permissions
|
|
153
|
+
const apiKey = c.get("apiKey");
|
|
154
|
+
|
|
155
|
+
// Check if API Key is strictly required for this collection
|
|
156
|
+
if (collection.access?.requireApiKey && !apiKey) {
|
|
157
|
+
const user = c.get("user");
|
|
158
|
+
// Allow access for authenticated admins even without an API Key
|
|
159
|
+
if (user?.role === "admin" || (Array.isArray(user?.role) && user.role.includes("admin"))) {
|
|
160
|
+
// Proceed to normal access function check
|
|
161
|
+
} else {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (apiKey) {
|
|
167
|
+
// If the key has specific permissions for this collection, we enforce them.
|
|
168
|
+
if (apiKey.permissions) {
|
|
169
|
+
const collectionPermissions = apiKey.permissions[collection.slug];
|
|
170
|
+
if (collectionPermissions) {
|
|
171
|
+
if (!collectionPermissions.includes(action as string)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. Fallback to Collection Access Function
|
|
180
|
+
if (access === undefined) return true;
|
|
181
|
+
if (typeof access === "boolean") return access;
|
|
182
|
+
|
|
183
|
+
const user = c.get("user");
|
|
184
|
+
const session = c.get("session");
|
|
185
|
+
|
|
186
|
+
return await access({
|
|
187
|
+
req: c,
|
|
188
|
+
user,
|
|
189
|
+
session,
|
|
190
|
+
apiKey,
|
|
191
|
+
data,
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Gets field access permissions
|
|
197
|
+
* @param c context
|
|
198
|
+
* @param operation operation type
|
|
199
|
+
* @param fields fields to check
|
|
200
|
+
* @param data data to check
|
|
201
|
+
* @returns field access permissions
|
|
202
|
+
*/
|
|
203
|
+
const getFieldAccessPermissions = async (
|
|
204
|
+
c: Context,
|
|
205
|
+
operation: "read" | "create" | "update",
|
|
206
|
+
fields: any[],
|
|
207
|
+
data?: unknown,
|
|
208
|
+
) => {
|
|
209
|
+
const permissions: Record<string, any> = {};
|
|
210
|
+
const user = c.get("user");
|
|
211
|
+
const session = c.get("session");
|
|
212
|
+
const apiKey = c.get("apiKey");
|
|
213
|
+
|
|
214
|
+
const accessArgs = {
|
|
215
|
+
req: c,
|
|
216
|
+
user,
|
|
217
|
+
session,
|
|
218
|
+
apiKey,
|
|
219
|
+
data,
|
|
220
|
+
operation,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const field of fields) {
|
|
224
|
+
if (field.name) {
|
|
225
|
+
if (!field.access) {
|
|
226
|
+
permissions[field.name] = { hidden: false, readOnly: false, disabled: false };
|
|
227
|
+
} else {
|
|
228
|
+
const hidden =
|
|
229
|
+
typeof field.access.hidden === "function"
|
|
230
|
+
? await field.access.hidden(accessArgs as any)
|
|
231
|
+
: !!field.access.hidden;
|
|
232
|
+
const readOnly =
|
|
233
|
+
typeof field.access.readOnly === "function"
|
|
234
|
+
? await field.access.readOnly(accessArgs as any)
|
|
235
|
+
: !!field.access.readOnly;
|
|
236
|
+
const disabled =
|
|
237
|
+
typeof field.access.disabled === "function"
|
|
238
|
+
? await field.access.disabled(accessArgs as any)
|
|
239
|
+
: !!field.access.disabled;
|
|
240
|
+
|
|
241
|
+
permissions[field.name] = { hidden, readOnly, disabled };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Recursively check nested fields if any
|
|
246
|
+
if (field.fields && Array.isArray(field.fields)) {
|
|
247
|
+
const nestedPermissions = await getFieldAccessPermissions(c, operation, field.fields, data);
|
|
248
|
+
Object.assign(permissions, nestedPermissions);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (field.tabs && Array.isArray(field.tabs)) {
|
|
252
|
+
for (const tab of field.tabs) {
|
|
253
|
+
if (tab.fields && Array.isArray(tab.fields)) {
|
|
254
|
+
const nestedPermissions = await getFieldAccessPermissions(
|
|
255
|
+
c,
|
|
256
|
+
operation,
|
|
257
|
+
tab.fields,
|
|
258
|
+
data,
|
|
259
|
+
);
|
|
260
|
+
Object.assign(permissions, nestedPermissions);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return permissions;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Saves a document version to the _versions table
|
|
271
|
+
*/
|
|
272
|
+
const saveVersion = async (doc: any, status?: string) => {
|
|
273
|
+
if (!collection.versions) return;
|
|
274
|
+
|
|
275
|
+
const versionsTable = `${collection.slug}_versions`.toLowerCase();
|
|
276
|
+
const versionDoc = {
|
|
277
|
+
id: crypto.randomUUID(),
|
|
278
|
+
_parent_id: doc.id,
|
|
279
|
+
_version_data: JSON.stringify(doc),
|
|
280
|
+
_status: status || doc._status || "published",
|
|
281
|
+
created_at: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await (db as any).unsafe(
|
|
286
|
+
`INSERT INTO ${versionsTable} (id, _parent_id, _version_data, _status, created_at) VALUES(?, ?, ?, ?, ?)`,
|
|
287
|
+
[
|
|
288
|
+
versionDoc.id,
|
|
289
|
+
versionDoc._parent_id,
|
|
290
|
+
versionDoc._version_data,
|
|
291
|
+
versionDoc._status,
|
|
292
|
+
versionDoc.created_at,
|
|
293
|
+
],
|
|
294
|
+
);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error(`[OpacaCMS] Failed to save version for ${collection.slug}: `, err);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
async find(c: Context) {
|
|
302
|
+
if (!(await checkAccess(c, "read"))) {
|
|
303
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const queries = c.req.query();
|
|
307
|
+
const maxLimit = config.api?.maxLimit ?? 100;
|
|
308
|
+
const page = queries.page ? parseInt(queries.page, 10) : 1;
|
|
309
|
+
let limit = queries.limit ? parseInt(queries.limit, 10) : 10;
|
|
310
|
+
if (limit > maxLimit) limit = maxLimit;
|
|
311
|
+
|
|
312
|
+
const sort = queries.sort as string;
|
|
313
|
+
const populate = queries.populate ? queries.populate.split(",") : [];
|
|
314
|
+
|
|
315
|
+
const filter: Record<string, unknown> = {};
|
|
316
|
+
for (const [key, value] of Object.entries(queries)) {
|
|
317
|
+
if (["page", "limit", "sort", "populate", "locale", "draft"].includes(key)) continue;
|
|
318
|
+
const match = key.match(/^([^[]+)\[([^\]]+)\]$/);
|
|
319
|
+
if (match) {
|
|
320
|
+
const field = match[1] as string;
|
|
321
|
+
const op = match[2] as string;
|
|
322
|
+
if (!filter[field]) filter[field] = {};
|
|
323
|
+
(filter[field] as Record<string, unknown>)[op] = value;
|
|
324
|
+
} else {
|
|
325
|
+
filter[key] = value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const results = await db.find(collection.slug, filter, { page, limit, sort });
|
|
330
|
+
|
|
331
|
+
if (populate.length > 0) {
|
|
332
|
+
results.docs = await Promise.all(
|
|
333
|
+
results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate)),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
results.docs = await Promise.all(
|
|
338
|
+
results.docs.map((doc) => hydrateDoc(doc, collection.fields, c, config)),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
return c.json(results);
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async findOne(c: Context) {
|
|
345
|
+
if (!(await checkAccess(c, "read"))) {
|
|
346
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const queries = c.req.query();
|
|
350
|
+
const populate = queries.populate ? queries.populate.split(",") : [];
|
|
351
|
+
const id = c.req.param("id");
|
|
352
|
+
|
|
353
|
+
let doc = await db.findOne(collection.slug, { id });
|
|
354
|
+
if (!doc) return c.json({ message: "Not found" }, 404);
|
|
355
|
+
|
|
356
|
+
if (populate.length > 0) {
|
|
357
|
+
doc = await populateDoc(db, collection.fields, doc, populate);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
doc = await hydrateDoc(doc, collection.fields, c, config);
|
|
361
|
+
|
|
362
|
+
const permissions = await getFieldAccessPermissions(c, "read", collection.fields, doc);
|
|
363
|
+
const cleanDoc = { ...doc } as any;
|
|
364
|
+
for (const [field, perm] of Object.entries(permissions)) {
|
|
365
|
+
if ((perm as any).hidden) {
|
|
366
|
+
delete cleanDoc[field];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return c.json(cleanDoc);
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
async create(c: Context) {
|
|
374
|
+
const body = await c.req.json();
|
|
375
|
+
if (!(await checkAccess(c, "create", body))) {
|
|
376
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Handle Slug Auto-generation
|
|
380
|
+
for (const field of collection.fields) {
|
|
381
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
382
|
+
const fromValue = body[field.from];
|
|
383
|
+
if (fromValue && typeof fromValue === "string") {
|
|
384
|
+
const formatted = field.format
|
|
385
|
+
? field.format(fromValue)
|
|
386
|
+
: fromValue
|
|
387
|
+
.toLowerCase()
|
|
388
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
389
|
+
.replace(/^-+|-+$/g, "");
|
|
390
|
+
body[field.name] = formatted;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const schema = generateSchemaForCollection(collection);
|
|
396
|
+
const validation = schema.safeParse(body);
|
|
397
|
+
if (!validation.success) {
|
|
398
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let data = validation.data;
|
|
402
|
+
const locale = c.req.header("x-opaca-locale");
|
|
403
|
+
|
|
404
|
+
// Wrap localized fields if a specific locale is provided
|
|
405
|
+
if (locale && locale !== "all") {
|
|
406
|
+
for (const field of collection.fields) {
|
|
407
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
408
|
+
data[field.name] = { [locale]: data[field.name] };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (collection.hooks?.beforeCreate) {
|
|
413
|
+
data = (await collection.hooks.beforeCreate(data as never)) as any;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const doc = await db.create(collection.slug, data as Record<string, unknown>);
|
|
417
|
+
|
|
418
|
+
if (collection.hooks?.afterCreate) {
|
|
419
|
+
await collection.hooks.afterCreate(doc as never);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (collection.webhooks) {
|
|
423
|
+
const afterCreateWebhooks = collection.webhooks.filter((w) =>
|
|
424
|
+
w.events.includes("afterCreate"),
|
|
425
|
+
);
|
|
426
|
+
for (const webhook of afterCreateWebhooks) {
|
|
427
|
+
const hookPromise = fetch(webhook.url, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
430
|
+
body: JSON.stringify(doc),
|
|
431
|
+
}).catch((e) => logger.error(`Webhook afterCreate failed for ${collection.slug}: `, e));
|
|
432
|
+
if (c.executionCtx?.waitUntil) {
|
|
433
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (collection.versions) {
|
|
439
|
+
await saveVersion(doc, body._status);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
443
|
+
|
|
444
|
+
return c.json(hydratedDoc, 201);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
async update(c: Context) {
|
|
448
|
+
const id = c.req.param("id");
|
|
449
|
+
const body = await c.req.json();
|
|
450
|
+
if (!(await checkAccess(c, "update", body))) {
|
|
451
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Handle Slug Auto-generation
|
|
455
|
+
for (const field of collection.fields) {
|
|
456
|
+
if (field.type === "slug" && !body[field.name]) {
|
|
457
|
+
const fromValue = body[field.from];
|
|
458
|
+
if (fromValue && typeof fromValue === "string") {
|
|
459
|
+
const formatted = field.format
|
|
460
|
+
? field.format(fromValue)
|
|
461
|
+
: fromValue
|
|
462
|
+
.toLowerCase()
|
|
463
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
464
|
+
.replace(/^-+|-+$/g, "");
|
|
465
|
+
body[field.name] = formatted;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const schema = generateSchemaForCollection(collection, true);
|
|
471
|
+
const validation = schema.safeParse(body);
|
|
472
|
+
if (!validation.success) {
|
|
473
|
+
return c.json({ message: "Validation Error", errors: validation.error.format() }, 400);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let data = validation.data;
|
|
477
|
+
const locale = c.req.header("x-opaca-locale");
|
|
478
|
+
|
|
479
|
+
// Merge into localized fields if a specific locale is provided
|
|
480
|
+
if (locale && locale !== "all") {
|
|
481
|
+
let existing: any = null;
|
|
482
|
+
for (const field of collection.fields) {
|
|
483
|
+
if (field.name && field.localized && data[field.name] !== undefined) {
|
|
484
|
+
if (!existing) {
|
|
485
|
+
existing = await db.findOne(collection.slug, { id });
|
|
486
|
+
}
|
|
487
|
+
const currentObj = (existing?.[field.name] || {}) as Record<string, any>;
|
|
488
|
+
data[field.name] = { ...currentObj, [locale]: data[field.name] };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (collection.hooks?.beforeUpdate) {
|
|
493
|
+
data = (await collection.hooks.beforeUpdate(data as never)) as any;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const doc = await db.update(collection.slug, { id }, data as Record<string, unknown>);
|
|
497
|
+
|
|
498
|
+
if (collection.hooks?.afterUpdate) {
|
|
499
|
+
await collection.hooks.afterUpdate(doc as never);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (collection.webhooks) {
|
|
503
|
+
const afterUpdateWebhooks = collection.webhooks.filter((w) =>
|
|
504
|
+
w.events.includes("afterUpdate"),
|
|
505
|
+
);
|
|
506
|
+
for (const webhook of afterUpdateWebhooks) {
|
|
507
|
+
const hookPromise = fetch(webhook.url, {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
510
|
+
body: JSON.stringify(doc),
|
|
511
|
+
}).catch((e) => logger.error(`Webhook afterUpdate failed for ${collection.slug}: `, e));
|
|
512
|
+
if (c.executionCtx?.waitUntil) {
|
|
513
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (collection.versions) {
|
|
519
|
+
await saveVersion(doc, body._status);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const hydratedDoc = await hydrateDoc(doc, collection.fields, c, config);
|
|
523
|
+
|
|
524
|
+
return c.json(hydratedDoc);
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
async delete(c: Context) {
|
|
528
|
+
if (!(await checkAccess(c, "delete"))) {
|
|
529
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const id = c.req.param("id");
|
|
533
|
+
|
|
534
|
+
// Optionally fetch doc before deleting to pass to webhook
|
|
535
|
+
let docToPass: any = { id };
|
|
536
|
+
try {
|
|
537
|
+
if (
|
|
538
|
+
collection.webhooks &&
|
|
539
|
+
collection.webhooks.some((w) => w.events.includes("afterDelete"))
|
|
540
|
+
) {
|
|
541
|
+
docToPass = await db.findOne(collection.slug, { id });
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {
|
|
544
|
+
// Ignore
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (collection.hooks?.beforeDelete) {
|
|
548
|
+
await collection.hooks.beforeDelete(id as never);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
await db.delete(collection.slug, { id });
|
|
552
|
+
|
|
553
|
+
if (collection.hooks?.afterDelete) {
|
|
554
|
+
await collection.hooks.afterDelete(id as never);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (collection.webhooks) {
|
|
558
|
+
const afterDeleteWebhooks = collection.webhooks.filter((w) =>
|
|
559
|
+
w.events.includes("afterDelete"),
|
|
560
|
+
);
|
|
561
|
+
for (const webhook of afterDeleteWebhooks) {
|
|
562
|
+
const hookPromise = fetch(webhook.url, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
headers: { "Content-Type": "application/json", ...webhook.headers },
|
|
565
|
+
body: JSON.stringify(docToPass || { id }),
|
|
566
|
+
}).catch((e) => logger.error(`Webhook afterDelete failed for ${collection.slug}: `, e));
|
|
567
|
+
if (c.executionCtx?.waitUntil) {
|
|
568
|
+
c.executionCtx.waitUntil(hookPromise);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return c.json({ message: "Deleted" });
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
async findVersions(c: Context) {
|
|
577
|
+
if (!(await checkAccess(c, "read"))) {
|
|
578
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
579
|
+
}
|
|
580
|
+
const parentId = c.req.query("parentId");
|
|
581
|
+
const versionsTable = `${collection.slug} _versions`.toLowerCase();
|
|
582
|
+
|
|
583
|
+
const query = parentId
|
|
584
|
+
? `SELECT * FROM ${versionsTable} WHERE _parent_id = ? ORDER BY created_at DESC`
|
|
585
|
+
: `SELECT * FROM ${versionsTable} ORDER BY created_at DESC`;
|
|
586
|
+
const params = parentId ? [parentId] : [];
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const rows = (await (db as any).unsafe(query, params)) as any[];
|
|
590
|
+
return c.json({ docs: rows });
|
|
591
|
+
} catch (err) {
|
|
592
|
+
return c.json({ message: "Failed to fetch versions", error: (err as any).message }, 500);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
async restoreVersion(c: Context) {
|
|
597
|
+
if (!(await checkAccess(c, "update"))) {
|
|
598
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
599
|
+
}
|
|
600
|
+
const versionId = c.req.param("versionId");
|
|
601
|
+
const versionsTable = `${collection.slug} _versions`.toLowerCase();
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const versionRows = (await (db as any).unsafe(
|
|
605
|
+
`SELECT * FROM ${versionsTable} WHERE id = ? `,
|
|
606
|
+
[versionId],
|
|
607
|
+
)) as any[];
|
|
608
|
+
if (versionRows.length === 0) return c.json({ message: "Version not found" }, 404);
|
|
609
|
+
|
|
610
|
+
const version = versionRows[0];
|
|
611
|
+
const data = JSON.parse(version._version_data);
|
|
612
|
+
|
|
613
|
+
// Remove ID and other internal fields for update
|
|
614
|
+
const id = version._parent_id;
|
|
615
|
+
delete data.id;
|
|
616
|
+
delete data.created_at;
|
|
617
|
+
delete data.updated_at;
|
|
618
|
+
|
|
619
|
+
const doc = await db.update(collection.slug, { id }, data);
|
|
620
|
+
await saveVersion(doc, "published"); // Save a new version tracking the restoration
|
|
621
|
+
|
|
622
|
+
return c.json(doc);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return c.json({ message: "Failed to restore version", error: (err as any).message }, 500);
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Creates handlers for global collections
|
|
632
|
+
* @param config opaca config
|
|
633
|
+
* @param globalConfig global config
|
|
634
|
+
* @param getAuth auth function
|
|
635
|
+
* @returns global handlers
|
|
636
|
+
*/
|
|
637
|
+
export function createGlobalHandlers(
|
|
638
|
+
config: OpacaConfig,
|
|
639
|
+
globalConfig: Global,
|
|
640
|
+
getAuth?: () => any,
|
|
641
|
+
) {
|
|
642
|
+
const { db } = config;
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Checks access to a global collection
|
|
646
|
+
* @param c context
|
|
647
|
+
* @param action action to check
|
|
648
|
+
* @param data data to check
|
|
649
|
+
* @returns true if access is allowed, false otherwise
|
|
650
|
+
*/
|
|
651
|
+
const checkAccess = async (
|
|
652
|
+
c: Context,
|
|
653
|
+
action: keyof NonNullable<Global["access"]>,
|
|
654
|
+
data?: unknown,
|
|
655
|
+
) => {
|
|
656
|
+
const access = globalConfig.access?.[action];
|
|
657
|
+
if (access === undefined) return true;
|
|
658
|
+
if (typeof access === "boolean") return access;
|
|
659
|
+
|
|
660
|
+
const user = c.get("user");
|
|
661
|
+
const session = c.get("session");
|
|
662
|
+
const apiKey = c.get("apiKey");
|
|
663
|
+
|
|
664
|
+
return await access({
|
|
665
|
+
req: c,
|
|
666
|
+
user,
|
|
667
|
+
session,
|
|
668
|
+
apiKey,
|
|
669
|
+
data,
|
|
670
|
+
});
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
async find(c: Context) {
|
|
675
|
+
if (!(await checkAccess(c, "read"))) {
|
|
676
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (!db.findGlobal) {
|
|
680
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const doc = await db.findGlobal(globalConfig.slug);
|
|
684
|
+
const hydratedDoc = await hydrateDoc(doc || {}, globalConfig.fields, c, config);
|
|
685
|
+
return c.json(hydratedDoc);
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
async update(c: Context) {
|
|
689
|
+
const body = await c.req.json();
|
|
690
|
+
|
|
691
|
+
if (!(await checkAccess(c, "update", body))) {
|
|
692
|
+
return c.json({ message: "Forbidden" }, 403);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!db.updateGlobal) {
|
|
696
|
+
return c.json({ message: "Globals are not supported by this database adapter" }, 501);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const schema = generateSchemaForCollection(globalConfig, true);
|
|
700
|
+
const validation = schema.safeParse(body);
|
|
701
|
+
|
|
702
|
+
if (!validation.success) {
|
|
703
|
+
logger.error(
|
|
704
|
+
`Validation Error on Global ${globalConfig.slug}: `,
|
|
705
|
+
validation.error.format(),
|
|
706
|
+
);
|
|
707
|
+
return c.json(
|
|
708
|
+
{
|
|
709
|
+
message: "Validation Error",
|
|
710
|
+
errors: validation.error.format(),
|
|
711
|
+
},
|
|
712
|
+
400,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const doc = await db.updateGlobal(globalConfig.slug, validation.data);
|
|
717
|
+
const hydratedDoc = await hydrateDoc(doc, globalConfig.fields, c, config);
|
|
718
|
+
|
|
719
|
+
return c.json(hydratedDoc);
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|