convex-cms 0.0.1
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/dist/cli/commands/admin.d.ts +16 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +88 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/detectConvexUrl.d.ts +13 -0
- package/dist/cli/utils/detectConvexUrl.d.ts.map +1 -0
- package/dist/cli/utils/detectConvexUrl.js +48 -0
- package/dist/cli/utils/detectConvexUrl.js.map +1 -0
- package/dist/cli/utils/openBrowser.d.ts +7 -0
- package/dist/cli/utils/openBrowser.d.ts.map +1 -0
- package/dist/cli/utils/openBrowser.js +17 -0
- package/dist/cli/utils/openBrowser.js.map +1 -0
- package/dist/client/admin-config.d.ts +126 -0
- package/dist/client/admin-config.d.ts.map +1 -0
- package/dist/client/admin-config.js +117 -0
- package/dist/client/admin-config.js.map +1 -0
- package/dist/client/adminApi.d.ts +2273 -0
- package/dist/client/adminApi.d.ts.map +1 -0
- package/dist/client/adminApi.js +716 -0
- package/dist/client/adminApi.js.map +1 -0
- package/dist/client/agentTools.d.ts +933 -0
- package/dist/client/agentTools.d.ts.map +1 -0
- package/dist/client/agentTools.js +1004 -0
- package/dist/client/agentTools.js.map +1 -0
- package/dist/client/argTypes.d.ts +212 -0
- package/dist/client/argTypes.d.ts.map +1 -0
- package/dist/client/argTypes.js +5 -0
- package/dist/client/argTypes.js.map +1 -0
- package/dist/client/field-types.d.ts +55 -0
- package/dist/client/field-types.d.ts.map +1 -0
- package/dist/client/field-types.js +152 -0
- package/dist/client/field-types.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +668 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/queryBuilder.d.ts +765 -0
- package/dist/client/queryBuilder.d.ts.map +1 -0
- package/dist/client/queryBuilder.js +970 -0
- package/dist/client/queryBuilder.js.map +1 -0
- package/dist/client/schema/codegen.d.ts +128 -0
- package/dist/client/schema/codegen.d.ts.map +1 -0
- package/dist/client/schema/codegen.js +318 -0
- package/dist/client/schema/codegen.js.map +1 -0
- package/dist/client/schema/defineContentType.d.ts +221 -0
- package/dist/client/schema/defineContentType.d.ts.map +1 -0
- package/dist/client/schema/defineContentType.js +380 -0
- package/dist/client/schema/defineContentType.js.map +1 -0
- package/dist/client/schema/index.d.ts +85 -0
- package/dist/client/schema/index.d.ts.map +1 -0
- package/dist/client/schema/index.js +92 -0
- package/dist/client/schema/index.js.map +1 -0
- package/dist/client/schema/schemaDrift.d.ts +199 -0
- package/dist/client/schema/schemaDrift.d.ts.map +1 -0
- package/dist/client/schema/schemaDrift.js +340 -0
- package/dist/client/schema/schemaDrift.js.map +1 -0
- package/dist/client/schema/typedClient.d.ts +401 -0
- package/dist/client/schema/typedClient.d.ts.map +1 -0
- package/dist/client/schema/typedClient.js +269 -0
- package/dist/client/schema/typedClient.js.map +1 -0
- package/dist/client/schema/types.d.ts +477 -0
- package/dist/client/schema/types.d.ts.map +1 -0
- package/dist/client/schema/types.js +39 -0
- package/dist/client/schema/types.js.map +1 -0
- package/dist/client/types.d.ts +449 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +149 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/workflows.d.ts +51 -0
- package/dist/client/workflows.d.ts.map +1 -0
- package/dist/client/workflows.js +103 -0
- package/dist/client/workflows.js.map +1 -0
- package/dist/client/wrapper.d.ts +2198 -0
- package/dist/client/wrapper.d.ts.map +1 -0
- package/dist/client/wrapper.js +2651 -0
- package/dist/client/wrapper.js.map +1 -0
- package/dist/component/_generated/api.d.ts +124 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +4321 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/auditLog.d.ts +410 -0
- package/dist/component/auditLog.d.ts.map +1 -0
- package/dist/component/auditLog.js +607 -0
- package/dist/component/auditLog.js.map +1 -0
- package/dist/component/authorization.d.ts +323 -0
- package/dist/component/authorization.d.ts.map +1 -0
- package/dist/component/authorization.js +464 -0
- package/dist/component/authorization.js.map +1 -0
- package/dist/component/authorizationHooks.d.ts +184 -0
- package/dist/component/authorizationHooks.d.ts.map +1 -0
- package/dist/component/authorizationHooks.js +521 -0
- package/dist/component/authorizationHooks.js.map +1 -0
- package/dist/component/bulkOperations.d.ts +200 -0
- package/dist/component/bulkOperations.d.ts.map +1 -0
- package/dist/component/bulkOperations.js +568 -0
- package/dist/component/bulkOperations.js.map +1 -0
- package/dist/component/contentEntries.d.ts +719 -0
- package/dist/component/contentEntries.d.ts.map +1 -0
- package/dist/component/contentEntries.js +1617 -0
- package/dist/component/contentEntries.js.map +1 -0
- package/dist/component/contentEntryMutations.d.ts +505 -0
- package/dist/component/contentEntryMutations.d.ts.map +1 -0
- package/dist/component/contentEntryMutations.js +1009 -0
- package/dist/component/contentEntryMutations.js.map +1 -0
- package/dist/component/contentEntryValidation.d.ts +115 -0
- package/dist/component/contentEntryValidation.d.ts.map +1 -0
- package/dist/component/contentEntryValidation.js +546 -0
- package/dist/component/contentEntryValidation.js.map +1 -0
- package/dist/component/contentLock.d.ts +328 -0
- package/dist/component/contentLock.d.ts.map +1 -0
- package/dist/component/contentLock.js +471 -0
- package/dist/component/contentLock.js.map +1 -0
- package/dist/component/contentTypeMigration.d.ts +411 -0
- package/dist/component/contentTypeMigration.d.ts.map +1 -0
- package/dist/component/contentTypeMigration.js +805 -0
- package/dist/component/contentTypeMigration.js.map +1 -0
- package/dist/component/contentTypeMutations.d.ts +975 -0
- package/dist/component/contentTypeMutations.d.ts.map +1 -0
- package/dist/component/contentTypeMutations.js +768 -0
- package/dist/component/contentTypeMutations.js.map +1 -0
- package/dist/component/contentTypes.d.ts +538 -0
- package/dist/component/contentTypes.d.ts.map +1 -0
- package/dist/component/contentTypes.js +304 -0
- package/dist/component/contentTypes.js.map +1 -0
- package/dist/component/convex.config.d.ts +42 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +43 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/documentTypes.d.ts +186 -0
- package/dist/component/documentTypes.d.ts.map +1 -0
- package/dist/component/documentTypes.js +23 -0
- package/dist/component/documentTypes.js.map +1 -0
- package/dist/component/eventEmitter.d.ts +281 -0
- package/dist/component/eventEmitter.d.ts.map +1 -0
- package/dist/component/eventEmitter.js +300 -0
- package/dist/component/eventEmitter.js.map +1 -0
- package/dist/component/exportImport.d.ts +1120 -0
- package/dist/component/exportImport.d.ts.map +1 -0
- package/dist/component/exportImport.js +931 -0
- package/dist/component/exportImport.js.map +1 -0
- package/dist/component/index.d.ts +28 -0
- package/dist/component/index.d.ts.map +1 -0
- package/dist/component/index.js +142 -0
- package/dist/component/index.js.map +1 -0
- package/dist/component/lib/deepReferenceResolver.d.ts +252 -0
- package/dist/component/lib/deepReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/deepReferenceResolver.js +601 -0
- package/dist/component/lib/deepReferenceResolver.js.map +1 -0
- package/dist/component/lib/errors.d.ts +306 -0
- package/dist/component/lib/errors.d.ts.map +1 -0
- package/dist/component/lib/errors.js +407 -0
- package/dist/component/lib/errors.js.map +1 -0
- package/dist/component/lib/index.d.ts +10 -0
- package/dist/component/lib/index.d.ts.map +1 -0
- package/dist/component/lib/index.js +33 -0
- package/dist/component/lib/index.js.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts +217 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.js +326 -0
- package/dist/component/lib/mediaReferenceResolver.js.map +1 -0
- package/dist/component/lib/metadataExtractor.d.ts +245 -0
- package/dist/component/lib/metadataExtractor.d.ts.map +1 -0
- package/dist/component/lib/metadataExtractor.js +548 -0
- package/dist/component/lib/metadataExtractor.js.map +1 -0
- package/dist/component/lib/mutationAuth.d.ts +95 -0
- package/dist/component/lib/mutationAuth.d.ts.map +1 -0
- package/dist/component/lib/mutationAuth.js +146 -0
- package/dist/component/lib/mutationAuth.js.map +1 -0
- package/dist/component/lib/queries.d.ts +17 -0
- package/dist/component/lib/queries.d.ts.map +1 -0
- package/dist/component/lib/queries.js +49 -0
- package/dist/component/lib/queries.js.map +1 -0
- package/dist/component/lib/ragContentChunker.d.ts +423 -0
- package/dist/component/lib/ragContentChunker.d.ts.map +1 -0
- package/dist/component/lib/ragContentChunker.js +897 -0
- package/dist/component/lib/ragContentChunker.js.map +1 -0
- package/dist/component/lib/referenceResolver.d.ts +175 -0
- package/dist/component/lib/referenceResolver.d.ts.map +1 -0
- package/dist/component/lib/referenceResolver.js +293 -0
- package/dist/component/lib/referenceResolver.js.map +1 -0
- package/dist/component/lib/slugGenerator.d.ts +71 -0
- package/dist/component/lib/slugGenerator.d.ts.map +1 -0
- package/dist/component/lib/slugGenerator.js +207 -0
- package/dist/component/lib/slugGenerator.js.map +1 -0
- package/dist/component/lib/slugUniqueness.d.ts +131 -0
- package/dist/component/lib/slugUniqueness.d.ts.map +1 -0
- package/dist/component/lib/slugUniqueness.js +229 -0
- package/dist/component/lib/slugUniqueness.js.map +1 -0
- package/dist/component/lib/softDelete.d.ts +18 -0
- package/dist/component/lib/softDelete.d.ts.map +1 -0
- package/dist/component/lib/softDelete.js +29 -0
- package/dist/component/lib/softDelete.js.map +1 -0
- package/dist/component/localeFallbackChain.d.ts +410 -0
- package/dist/component/localeFallbackChain.d.ts.map +1 -0
- package/dist/component/localeFallbackChain.js +467 -0
- package/dist/component/localeFallbackChain.js.map +1 -0
- package/dist/component/localeFields.d.ts +508 -0
- package/dist/component/localeFields.d.ts.map +1 -0
- package/dist/component/localeFields.js +592 -0
- package/dist/component/localeFields.js.map +1 -0
- package/dist/component/mediaAssetMutations.d.ts +235 -0
- package/dist/component/mediaAssetMutations.d.ts.map +1 -0
- package/dist/component/mediaAssetMutations.js +558 -0
- package/dist/component/mediaAssetMutations.js.map +1 -0
- package/dist/component/mediaAssets.d.ts +168 -0
- package/dist/component/mediaAssets.d.ts.map +1 -0
- package/dist/component/mediaAssets.js +618 -0
- package/dist/component/mediaAssets.js.map +1 -0
- package/dist/component/mediaFolderMutations.d.ts +642 -0
- package/dist/component/mediaFolderMutations.d.ts.map +1 -0
- package/dist/component/mediaFolderMutations.js +849 -0
- package/dist/component/mediaFolderMutations.js.map +1 -0
- package/dist/component/mediaUploadMutations.d.ts +136 -0
- package/dist/component/mediaUploadMutations.d.ts.map +1 -0
- package/dist/component/mediaUploadMutations.js +205 -0
- package/dist/component/mediaUploadMutations.js.map +1 -0
- package/dist/component/mediaVariantMutations.d.ts +468 -0
- package/dist/component/mediaVariantMutations.d.ts.map +1 -0
- package/dist/component/mediaVariantMutations.js +737 -0
- package/dist/component/mediaVariantMutations.js.map +1 -0
- package/dist/component/mediaVariants.d.ts +525 -0
- package/dist/component/mediaVariants.d.ts.map +1 -0
- package/dist/component/mediaVariants.js +661 -0
- package/dist/component/mediaVariants.js.map +1 -0
- package/dist/component/ragContentIndexer.d.ts +595 -0
- package/dist/component/ragContentIndexer.d.ts.map +1 -0
- package/dist/component/ragContentIndexer.js +794 -0
- package/dist/component/ragContentIndexer.js.map +1 -0
- package/dist/component/rateLimitHooks.d.ts +266 -0
- package/dist/component/rateLimitHooks.d.ts.map +1 -0
- package/dist/component/rateLimitHooks.js +412 -0
- package/dist/component/rateLimitHooks.js.map +1 -0
- package/dist/component/roles.d.ts +649 -0
- package/dist/component/roles.d.ts.map +1 -0
- package/dist/component/roles.js +884 -0
- package/dist/component/roles.js.map +1 -0
- package/dist/component/scheduledPublish.d.ts +182 -0
- package/dist/component/scheduledPublish.d.ts.map +1 -0
- package/dist/component/scheduledPublish.js +304 -0
- package/dist/component/scheduledPublish.js.map +1 -0
- package/dist/component/schema.d.ts +4114 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +469 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/taxonomies.d.ts +476 -0
- package/dist/component/taxonomies.d.ts.map +1 -0
- package/dist/component/taxonomies.js +785 -0
- package/dist/component/taxonomies.js.map +1 -0
- package/dist/component/taxonomyMutations.d.ts +206 -0
- package/dist/component/taxonomyMutations.d.ts.map +1 -0
- package/dist/component/taxonomyMutations.js +1001 -0
- package/dist/component/taxonomyMutations.js.map +1 -0
- package/dist/component/trash.d.ts +265 -0
- package/dist/component/trash.d.ts.map +1 -0
- package/dist/component/trash.js +621 -0
- package/dist/component/trash.js.map +1 -0
- package/dist/component/types.d.ts +4 -0
- package/dist/component/types.d.ts.map +1 -0
- package/dist/component/types.js +2 -0
- package/dist/component/types.js.map +1 -0
- package/dist/component/userContext.d.ts +508 -0
- package/dist/component/userContext.d.ts.map +1 -0
- package/dist/component/userContext.js +615 -0
- package/dist/component/userContext.js.map +1 -0
- package/dist/component/validation.d.ts +387 -0
- package/dist/component/validation.d.ts.map +1 -0
- package/dist/component/validation.js +1052 -0
- package/dist/component/validation.js.map +1 -0
- package/dist/component/validators.d.ts +4645 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +641 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/component/versionMutations.d.ts +216 -0
- package/dist/component/versionMutations.d.ts.map +1 -0
- package/dist/component/versionMutations.js +321 -0
- package/dist/component/versionMutations.js.map +1 -0
- package/dist/component/webhookTrigger.d.ts +770 -0
- package/dist/component/webhookTrigger.d.ts.map +1 -0
- package/dist/component/webhookTrigger.js +1413 -0
- package/dist/component/webhookTrigger.js.map +1 -0
- package/dist/react/index.d.ts +316 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +558 -0
- package/dist/react/index.js.map +1 -0
- package/dist/test.d.ts +2230 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +1107 -0
- package/dist/test.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/commands/admin.ts +104 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/detectConvexUrl.ts +54 -0
- package/src/cli/utils/openBrowser.ts +16 -0
- package/src/client/admin-config.ts +138 -0
- package/src/client/adminApi.ts +942 -0
- package/src/client/agentTools.ts +1311 -0
- package/src/client/argTypes.ts +316 -0
- package/src/client/field-types.ts +187 -0
- package/src/client/index.ts +1301 -0
- package/src/client/queryBuilder.ts +1100 -0
- package/src/client/schema/codegen.ts +500 -0
- package/src/client/schema/defineContentType.ts +501 -0
- package/src/client/schema/index.ts +169 -0
- package/src/client/schema/schemaDrift.ts +574 -0
- package/src/client/schema/typedClient.ts +688 -0
- package/src/client/schema/types.ts +666 -0
- package/src/client/types.ts +723 -0
- package/src/client/workflows.ts +141 -0
- package/src/client/wrapper.ts +4304 -0
- package/src/component/_generated/api.ts +140 -0
- package/src/component/_generated/component.ts +5029 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/authorization.ts +647 -0
- package/src/component/authorizationHooks.ts +668 -0
- package/src/component/bulkOperations.ts +687 -0
- package/src/component/contentEntries.ts +1976 -0
- package/src/component/contentEntryMutations.ts +1223 -0
- package/src/component/contentEntryValidation.ts +707 -0
- package/src/component/contentLock.ts +550 -0
- package/src/component/contentTypeMigration.ts +1064 -0
- package/src/component/contentTypeMutations.ts +969 -0
- package/src/component/contentTypes.ts +346 -0
- package/src/component/convex.config.ts +44 -0
- package/src/component/documentTypes.ts +240 -0
- package/src/component/eventEmitter.ts +485 -0
- package/src/component/exportImport.ts +1169 -0
- package/src/component/index.ts +491 -0
- package/src/component/lib/deepReferenceResolver.ts +999 -0
- package/src/component/lib/errors.ts +816 -0
- package/src/component/lib/index.ts +145 -0
- package/src/component/lib/mediaReferenceResolver.ts +495 -0
- package/src/component/lib/metadataExtractor.ts +792 -0
- package/src/component/lib/mutationAuth.ts +199 -0
- package/src/component/lib/queries.ts +79 -0
- package/src/component/lib/ragContentChunker.ts +1371 -0
- package/src/component/lib/referenceResolver.ts +430 -0
- package/src/component/lib/slugGenerator.ts +262 -0
- package/src/component/lib/slugUniqueness.ts +333 -0
- package/src/component/lib/softDelete.ts +44 -0
- package/src/component/localeFallbackChain.ts +673 -0
- package/src/component/localeFields.ts +896 -0
- package/src/component/mediaAssetMutations.ts +725 -0
- package/src/component/mediaAssets.ts +932 -0
- package/src/component/mediaFolderMutations.ts +1046 -0
- package/src/component/mediaUploadMutations.ts +224 -0
- package/src/component/mediaVariantMutations.ts +900 -0
- package/src/component/mediaVariants.ts +793 -0
- package/src/component/ragContentIndexer.ts +1067 -0
- package/src/component/rateLimitHooks.ts +572 -0
- package/src/component/roles.ts +1360 -0
- package/src/component/scheduledPublish.ts +358 -0
- package/src/component/schema.ts +617 -0
- package/src/component/taxonomies.ts +949 -0
- package/src/component/taxonomyMutations.ts +1210 -0
- package/src/component/trash.ts +724 -0
- package/src/component/userContext.ts +898 -0
- package/src/component/validation.ts +1388 -0
- package/src/component/validators.ts +949 -0
- package/src/component/versionMutations.ts +392 -0
- package/src/component/webhookTrigger.ts +1922 -0
- package/src/react/index.ts +898 -0
- package/src/test.ts +1580 -0
|
@@ -0,0 +1,2651 @@
|
|
|
1
|
+
/** CMS Client Wrapper with typed method APIs */
|
|
2
|
+
/** @internal Bridges wrapper's simplified types to generated Convex types */
|
|
3
|
+
function _callMutation(ctx, fn, args) {
|
|
4
|
+
return ctx.runMutation(fn, args);
|
|
5
|
+
}
|
|
6
|
+
/** @internal */
|
|
7
|
+
function callQuery(ctx, fn, args) {
|
|
8
|
+
return ctx.runQuery(fn, args);
|
|
9
|
+
}
|
|
10
|
+
import { resolveConfig, AuthorizationNotConfiguredError } from "./types.js";
|
|
11
|
+
// Import query builder
|
|
12
|
+
import { createQueryBuilder } from "./queryBuilder.js";
|
|
13
|
+
// Import authorization hooks execution
|
|
14
|
+
import { executeAuthorizationHooks, contextToRbacOptions, operationToRbac, } from "../component/authorizationHooks.js";
|
|
15
|
+
// Import rate limit hooks execution
|
|
16
|
+
import { requireRateLimit, createRateLimitContext, } from "../component/rateLimitHooks.js";
|
|
17
|
+
// Import RBAC utilities from component
|
|
18
|
+
import { hasPermission, hasContentTypePermission, getPermittedContentTypes, DEFAULT_ROLES, } from "../component/roles.js";
|
|
19
|
+
// Import locale fallback chain utilities
|
|
20
|
+
import { resolveFallbackChain, getFallbackChain, } from "../component/localeFallbackChain.js";
|
|
21
|
+
import { resolveLocaleContent, resolveLocaleContentBatch, } from "../component/localeFields.js";
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Content Types API Wrapper
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/** Content type CRUD operations */
|
|
26
|
+
export class ContentTypesApi {
|
|
27
|
+
api;
|
|
28
|
+
config;
|
|
29
|
+
authHelper;
|
|
30
|
+
rateLimitHelper;
|
|
31
|
+
constructor(api, config, authHelper, rateLimitHelper) {
|
|
32
|
+
this.api = api;
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.authHelper = authHelper;
|
|
35
|
+
this.rateLimitHelper = rateLimitHelper;
|
|
36
|
+
}
|
|
37
|
+
/** @throws AuthorizationNotConfiguredError if not configured and not in permissiveMode */
|
|
38
|
+
async authorize(ctx, operation, userId, resourceId) {
|
|
39
|
+
// Check if authorization is configured
|
|
40
|
+
if (!this.authHelper) {
|
|
41
|
+
if (this.config.permissiveMode) {
|
|
42
|
+
console.warn(`[ConvexCMS] Authorization not configured for "${operation}". ` +
|
|
43
|
+
"Operations are allowed in permissiveMode, but this should NOT be used in production. " +
|
|
44
|
+
"Configure getUserRole hook to enable proper authorization.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw new AuthorizationNotConfiguredError(operation);
|
|
48
|
+
}
|
|
49
|
+
// Skip RBAC checks if explicitly disabled
|
|
50
|
+
if (this.authHelper.skipRbac) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Check if userId is provided
|
|
54
|
+
if (!userId) {
|
|
55
|
+
if (this.config.permissiveMode) {
|
|
56
|
+
console.warn(`[ConvexCMS] Anonymous operation attempted for "${operation}". ` +
|
|
57
|
+
"Operations without userId are allowed in permissiveMode, but this should NOT be used in production.");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
throw new AuthorizationNotConfiguredError(`${operation} (no userId provided - anonymous operations require permissiveMode)`);
|
|
61
|
+
}
|
|
62
|
+
const role = await this.authHelper.getUserRole(ctx, userId);
|
|
63
|
+
await this.authHelper.requireAuthorization(ctx, {
|
|
64
|
+
operation,
|
|
65
|
+
userId,
|
|
66
|
+
role,
|
|
67
|
+
resourceId,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async rateLimit(ctx, operation, userId) {
|
|
71
|
+
if (!this.rateLimitHelper)
|
|
72
|
+
return;
|
|
73
|
+
const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
|
|
74
|
+
await this.rateLimitHelper.requireRateLimit(operation, { userId, role });
|
|
75
|
+
}
|
|
76
|
+
async create(ctx, args) {
|
|
77
|
+
await this.authorize(ctx, "contentTypes.create", args.createdBy);
|
|
78
|
+
await this.rateLimit(ctx, "contentTypes.create", args.createdBy);
|
|
79
|
+
return ctx.runMutation(this.api.contentTypeMutations.createContentType, args);
|
|
80
|
+
}
|
|
81
|
+
/** Detects breaking changes; fails unless force:true is specified */
|
|
82
|
+
async update(ctx, args) {
|
|
83
|
+
await this.authorize(ctx, "contentTypes.update", args.updatedBy, args.id);
|
|
84
|
+
await this.rateLimit(ctx, "contentTypes.update", args.updatedBy);
|
|
85
|
+
return ctx.runMutation(this.api.contentTypeMutations.updateContentType, args);
|
|
86
|
+
}
|
|
87
|
+
/** Soft delete by default; use hardDelete:true for permanent, cascade:true to delete entries */
|
|
88
|
+
async delete(ctx, args) {
|
|
89
|
+
await this.authorize(ctx, "contentTypes.delete", args.deletedBy, args.id);
|
|
90
|
+
await this.rateLimit(ctx, "contentTypes.delete", args.deletedBy);
|
|
91
|
+
return ctx.runMutation(this.api.contentTypeMutations.deleteContentType, args);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get a content type by ID or name.
|
|
95
|
+
*
|
|
96
|
+
* @param ctx - Convex query context
|
|
97
|
+
* @param args - Get arguments (id or name)
|
|
98
|
+
* @returns The content type or null if not found
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* // Get by ID (fastest - direct document lookup)
|
|
103
|
+
* const type = await cms.contentTypes.get(ctx, { id: typeId });
|
|
104
|
+
*
|
|
105
|
+
* // Get by name (uses index)
|
|
106
|
+
* const type = await cms.contentTypes.get(ctx, { name: "blog_post" });
|
|
107
|
+
*
|
|
108
|
+
* // Include soft-deleted types
|
|
109
|
+
* const type = await cms.contentTypes.get(ctx, {
|
|
110
|
+
* name: "archived_type",
|
|
111
|
+
* includeDeleted: true,
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
async get(ctx, args) {
|
|
116
|
+
return ctx.runQuery(this.api.contentTypes.get, args);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get a content type by name.
|
|
120
|
+
*
|
|
121
|
+
* Convenience method that wraps `get()` for name-based lookup.
|
|
122
|
+
*
|
|
123
|
+
* @param ctx - Convex query context
|
|
124
|
+
* @param name - The machine-readable name of the content type
|
|
125
|
+
* @param includeDeleted - Whether to include soft-deleted types
|
|
126
|
+
* @returns The content type or null if not found
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* const blogType = await cms.contentTypes.getByName(ctx, "blog_post");
|
|
131
|
+
* if (blogType) {
|
|
132
|
+
* console.log("Fields:", blogType.fields);
|
|
133
|
+
* }
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
async getByName(ctx, name, includeDeleted = false) {
|
|
137
|
+
return this.get(ctx, { name, includeDeleted });
|
|
138
|
+
}
|
|
139
|
+
async getById(ctx, id, includeDeleted = false) {
|
|
140
|
+
return this.get(ctx, { id, includeDeleted });
|
|
141
|
+
}
|
|
142
|
+
async exists(ctx, name, includeDeleted = false) {
|
|
143
|
+
const type = await this.getByName(ctx, name, includeDeleted);
|
|
144
|
+
return type !== null;
|
|
145
|
+
}
|
|
146
|
+
async list(ctx, args = {}) {
|
|
147
|
+
return ctx.runQuery(this.api.contentTypes.list, args);
|
|
148
|
+
}
|
|
149
|
+
async listActive(ctx, paginationOpts) {
|
|
150
|
+
return this.list(ctx, { isActive: true, includeDeleted: false, paginationOpts });
|
|
151
|
+
}
|
|
152
|
+
async getAll(ctx, includeInactive = false) {
|
|
153
|
+
const result = await this.list(ctx, { isActive: includeInactive ? undefined : true, includeDeleted: false });
|
|
154
|
+
return result.page;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* const count = await cms.contentTypes.count(ctx);
|
|
160
|
+
* console.log(`You have ${count} content types`);
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
async count(ctx, includeInactive = false) {
|
|
164
|
+
const all = await this.getAll(ctx, includeInactive);
|
|
165
|
+
return all.length;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Deactivate a content type without deleting it.
|
|
169
|
+
*
|
|
170
|
+
* Deactivated types remain in the database but are filtered out by default
|
|
171
|
+
* when listing content types. Existing content entries remain accessible.
|
|
172
|
+
*
|
|
173
|
+
* @param ctx - Convex mutation context
|
|
174
|
+
* @param id - The content type ID to deactivate
|
|
175
|
+
* @param updatedBy - User ID making the change
|
|
176
|
+
* @returns The updated content type
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* await cms.contentTypes.deactivate(ctx, contentTypeId, currentUserId);
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
async deactivate(ctx, id, updatedBy) {
|
|
184
|
+
return this.update(ctx, { id, isActive: false, updatedBy });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Reactivate a previously deactivated content type.
|
|
188
|
+
*
|
|
189
|
+
* @param ctx - Convex mutation context
|
|
190
|
+
* @param id - The content type ID to reactivate
|
|
191
|
+
* @param updatedBy - User ID making the change
|
|
192
|
+
* @returns The updated content type
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```typescript
|
|
196
|
+
* await cms.contentTypes.reactivate(ctx, contentTypeId, currentUserId);
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
async reactivate(ctx, id, updatedBy) {
|
|
200
|
+
return this.update(ctx, { id, isActive: true, updatedBy });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// =============================================================================
|
|
204
|
+
// Content Entries API Wrapper
|
|
205
|
+
// =============================================================================
|
|
206
|
+
/** Content entry CRUD and workflow operations */
|
|
207
|
+
export class ContentEntriesApi {
|
|
208
|
+
api;
|
|
209
|
+
config;
|
|
210
|
+
authHelper;
|
|
211
|
+
rateLimitHelper;
|
|
212
|
+
constructor(api, config, authHelper, rateLimitHelper) {
|
|
213
|
+
this.api = api;
|
|
214
|
+
this.config = config;
|
|
215
|
+
this.authHelper = authHelper;
|
|
216
|
+
this.rateLimitHelper = rateLimitHelper;
|
|
217
|
+
}
|
|
218
|
+
/** @throws AuthorizationNotConfiguredError if not configured and not in permissiveMode */
|
|
219
|
+
async authorize(ctx, operation, userId, resourceId, resourceOwnerId, contentTypeId) {
|
|
220
|
+
if (!this.authHelper) {
|
|
221
|
+
if (this.config.permissiveMode) {
|
|
222
|
+
console.warn(`[ConvexCMS] Authorization not configured for "${operation}". ` +
|
|
223
|
+
"Operations are allowed in permissiveMode, but this should NOT be used in production.");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
throw new AuthorizationNotConfiguredError(operation);
|
|
227
|
+
}
|
|
228
|
+
if (this.authHelper.skipRbac)
|
|
229
|
+
return;
|
|
230
|
+
if (!userId) {
|
|
231
|
+
if (this.config.permissiveMode) {
|
|
232
|
+
console.warn(`[ConvexCMS] Anonymous operation attempted for "${operation}". ` +
|
|
233
|
+
"Operations without userId are allowed in permissiveMode, but this should NOT be used in production.");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
throw new AuthorizationNotConfiguredError(`${operation} (no userId provided - anonymous operations require permissiveMode)`);
|
|
237
|
+
}
|
|
238
|
+
const role = await this.authHelper.getUserRole(ctx, userId);
|
|
239
|
+
await this.authHelper.requireAuthorization(ctx, { operation, userId, role, resourceId, resourceOwnerId, contentTypeId });
|
|
240
|
+
}
|
|
241
|
+
async rateLimit(ctx, operation, userId, contentTypeId) {
|
|
242
|
+
if (!this.rateLimitHelper)
|
|
243
|
+
return;
|
|
244
|
+
const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
|
|
245
|
+
await this.rateLimitHelper.requireRateLimit(operation, { userId, role, contentTypeId });
|
|
246
|
+
}
|
|
247
|
+
async create(ctx, args) {
|
|
248
|
+
await this.authorize(ctx, "contentEntries.create", args.createdBy, undefined, undefined, args.contentTypeId);
|
|
249
|
+
await this.rateLimit(ctx, "contentEntries.create", args.createdBy, args.contentTypeId);
|
|
250
|
+
const argsWithDefaults = { ...args, locale: args.locale ?? this.config.defaultLocale };
|
|
251
|
+
return ctx.runMutation(this.api.contentEntryMutations.createEntry, argsWithDefaults);
|
|
252
|
+
}
|
|
253
|
+
async update(ctx, args) {
|
|
254
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
255
|
+
if (!entry)
|
|
256
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
257
|
+
await this.authorize(ctx, "contentEntries.update", args.updatedBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
258
|
+
await this.rateLimit(ctx, "contentEntries.update", args.updatedBy, entry.contentTypeId);
|
|
259
|
+
return ctx.runMutation(this.api.contentEntryMutations.updateEntry, args);
|
|
260
|
+
}
|
|
261
|
+
async delete(ctx, args) {
|
|
262
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
263
|
+
if (!entry)
|
|
264
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
265
|
+
await this.authorize(ctx, "contentEntries.delete", args.deletedBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
266
|
+
await this.rateLimit(ctx, "contentEntries.delete", args.deletedBy, entry.contentTypeId);
|
|
267
|
+
return ctx.runMutation(this.api.contentEntryMutations.deleteEntry, args);
|
|
268
|
+
}
|
|
269
|
+
async get(ctx, args) {
|
|
270
|
+
return ctx.runQuery(this.api.contentEntries.get, args);
|
|
271
|
+
}
|
|
272
|
+
/** Looks up by contentTypeId+slug or contentTypeName+slug */
|
|
273
|
+
async getBySlug(ctx, args) {
|
|
274
|
+
// The wrapper's unified interface adapts to the component's split API
|
|
275
|
+
if (args.contentTypeId) {
|
|
276
|
+
return ctx.runQuery(this.api.contentEntries.getBySlug, {
|
|
277
|
+
contentTypeId: args.contentTypeId,
|
|
278
|
+
slug: args.slug,
|
|
279
|
+
includeDeleted: false,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (args.contentTypeName) {
|
|
283
|
+
return ctx.runQuery(this.api.contentEntries.getBySlugAndTypeName, {
|
|
284
|
+
contentTypeName: args.contentTypeName,
|
|
285
|
+
slug: args.slug,
|
|
286
|
+
includeDeleted: false,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
throw new Error("getBySlug requires either contentTypeId or contentTypeName");
|
|
290
|
+
}
|
|
291
|
+
/** Standard Convex pagination format compatible with usePaginatedQuery */
|
|
292
|
+
async list(ctx, args) {
|
|
293
|
+
return ctx.runQuery(this.api.contentEntries.list, args);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Publish a content entry.
|
|
297
|
+
*
|
|
298
|
+
* @param ctx - Convex mutation context
|
|
299
|
+
* @param args - Publish arguments
|
|
300
|
+
* @returns The published entry
|
|
301
|
+
*/
|
|
302
|
+
async publish(ctx, args) {
|
|
303
|
+
// Fetch entry for ownership-based authorization
|
|
304
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
305
|
+
if (!entry) {
|
|
306
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
307
|
+
}
|
|
308
|
+
// Authorization check - contentEntries.publish (with ownership info)
|
|
309
|
+
await this.authorize(ctx, "contentEntries.publish", args.updatedBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
310
|
+
// Rate limit check - contentEntries.publish
|
|
311
|
+
await this.rateLimit(ctx, "contentEntries.publish", args.updatedBy, entry.contentTypeId);
|
|
312
|
+
return ctx.runMutation(this.api.contentEntryMutations.publishEntry, args);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Unpublish a content entry (revert to draft).
|
|
316
|
+
*
|
|
317
|
+
* @param ctx - Convex mutation context
|
|
318
|
+
* @param args - Unpublish arguments
|
|
319
|
+
* @returns The unpublished entry
|
|
320
|
+
*/
|
|
321
|
+
async unpublish(ctx, args) {
|
|
322
|
+
// Fetch entry for ownership-based authorization
|
|
323
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
324
|
+
if (!entry) {
|
|
325
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
326
|
+
}
|
|
327
|
+
// Authorization check - contentEntries.unpublish (with ownership info)
|
|
328
|
+
await this.authorize(ctx, "contentEntries.unpublish", args.updatedBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
329
|
+
// Rate limit check - contentEntries.unpublish
|
|
330
|
+
await this.rateLimit(ctx, "contentEntries.unpublish", args.updatedBy, entry.contentTypeId);
|
|
331
|
+
return ctx.runMutation(this.api.contentEntryMutations.unpublishEntry, args);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Schedule a content entry for future publication.
|
|
335
|
+
*
|
|
336
|
+
* @param ctx - Convex mutation context
|
|
337
|
+
* @param args - Schedule arguments
|
|
338
|
+
* @returns The scheduled entry
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* ```typescript
|
|
342
|
+
* await cms.contentEntries.schedule(ctx, {
|
|
343
|
+
* id: entryId,
|
|
344
|
+
* publishAt: Date.now() + 24 * 60 * 60 * 1000, // Tomorrow
|
|
345
|
+
* });
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
async schedule(ctx, args) {
|
|
349
|
+
if (!this.config.features.scheduling) {
|
|
350
|
+
throw new Error("Scheduling feature is not enabled");
|
|
351
|
+
}
|
|
352
|
+
// Fetch entry for ownership-based authorization
|
|
353
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
354
|
+
if (!entry) {
|
|
355
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
356
|
+
}
|
|
357
|
+
// Authorization check - contentEntries.schedule (with ownership info)
|
|
358
|
+
await this.authorize(ctx, "contentEntries.schedule", args.updatedBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
359
|
+
// Rate limit check - contentEntries.schedule
|
|
360
|
+
await this.rateLimit(ctx, "contentEntries.schedule", args.updatedBy, entry.contentTypeId);
|
|
361
|
+
return ctx.runMutation(this.api.scheduledPublish.scheduleEntry, args);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Restore a soft-deleted content entry.
|
|
365
|
+
*
|
|
366
|
+
* Removes the deletedAt timestamp from a soft-deleted entry,
|
|
367
|
+
* making it active again. Only works for soft-deleted entries;
|
|
368
|
+
* hard-deleted entries cannot be recovered.
|
|
369
|
+
*
|
|
370
|
+
* @param ctx - Convex mutation context
|
|
371
|
+
* @param args - Restore arguments
|
|
372
|
+
* @returns The restored entry
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```typescript
|
|
376
|
+
* // Restore a soft-deleted entry
|
|
377
|
+
* const restored = await cms.contentEntries.restore(ctx, {
|
|
378
|
+
* id: entryId,
|
|
379
|
+
* restoredBy: currentUserId,
|
|
380
|
+
* });
|
|
381
|
+
* console.log(restored.deletedAt); // undefined
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
async restore(ctx, args) {
|
|
385
|
+
if (!this.config.features.softDelete) {
|
|
386
|
+
throw new Error("Soft delete feature is not enabled");
|
|
387
|
+
}
|
|
388
|
+
// Fetch entry for ownership-based authorization
|
|
389
|
+
const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
|
|
390
|
+
if (!entry) {
|
|
391
|
+
throw new Error(`Content entry not found: ${args.id}`);
|
|
392
|
+
}
|
|
393
|
+
// Authorization check - contentEntries.restore (with ownership info)
|
|
394
|
+
await this.authorize(ctx, "contentEntries.restore", args.restoredBy, args.id, entry.createdBy, entry.contentTypeId);
|
|
395
|
+
// Rate limit check - contentEntries.restore
|
|
396
|
+
await this.rateLimit(ctx, "contentEntries.restore", args.restoredBy, entry.contentTypeId);
|
|
397
|
+
return ctx.runMutation(this.api.contentEntryMutations.restoreEntry, args);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Create a fluent query builder for constructing complex content queries.
|
|
401
|
+
*
|
|
402
|
+
* The query builder provides a chainable API for building queries with:
|
|
403
|
+
* - Content type filtering
|
|
404
|
+
* - Status filtering (single or multiple)
|
|
405
|
+
* - Field-level filters with various operators
|
|
406
|
+
* - Full-text search
|
|
407
|
+
* - Locale filtering
|
|
408
|
+
* - Cursor-based pagination
|
|
409
|
+
* - Sort direction
|
|
410
|
+
*
|
|
411
|
+
* @returns A new ContentQueryBuilder instance
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```typescript
|
|
415
|
+
* // Simple query
|
|
416
|
+
* const posts = await cms.contentEntries
|
|
417
|
+
* .query()
|
|
418
|
+
* .contentType("blog_post")
|
|
419
|
+
* .status("published")
|
|
420
|
+
* .limit(10)
|
|
421
|
+
* .execute(ctx);
|
|
422
|
+
*
|
|
423
|
+
* // Complex query with field filters
|
|
424
|
+
* const featured = await cms.contentEntries
|
|
425
|
+
* .query()
|
|
426
|
+
* .contentType("blog_post")
|
|
427
|
+
* .where("category", "eq", "technology")
|
|
428
|
+
* .whereContains("tags", "featured")
|
|
429
|
+
* .whereGreaterThan("views", 100)
|
|
430
|
+
* .newestFirst()
|
|
431
|
+
* .limit(5)
|
|
432
|
+
* .execute(ctx);
|
|
433
|
+
*
|
|
434
|
+
* // Pagination
|
|
435
|
+
* const page1 = await cms.contentEntries
|
|
436
|
+
* .query()
|
|
437
|
+
* .contentType("blog_post")
|
|
438
|
+
* .limit(20)
|
|
439
|
+
* .execute(ctx);
|
|
440
|
+
*
|
|
441
|
+
* const page2 = await cms.contentEntries
|
|
442
|
+
* .query()
|
|
443
|
+
* .contentType("blog_post")
|
|
444
|
+
* .limit(20)
|
|
445
|
+
* .cursor(page1.continueCursor)
|
|
446
|
+
* .execute(ctx);
|
|
447
|
+
*
|
|
448
|
+
* // Get first result only
|
|
449
|
+
* const latest = await cms.contentEntries
|
|
450
|
+
* .query()
|
|
451
|
+
* .contentType("blog_post")
|
|
452
|
+
* .published()
|
|
453
|
+
* .newestFirst()
|
|
454
|
+
* .first(ctx);
|
|
455
|
+
*
|
|
456
|
+
* // Check if results exist
|
|
457
|
+
* const hasPublished = await cms.contentEntries
|
|
458
|
+
* .query()
|
|
459
|
+
* .contentType("blog_post")
|
|
460
|
+
* .published()
|
|
461
|
+
* .exists(ctx);
|
|
462
|
+
* ```
|
|
463
|
+
*/
|
|
464
|
+
query() {
|
|
465
|
+
return createQueryBuilder(this.api);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Resolve locale content for a single content entry.
|
|
469
|
+
*
|
|
470
|
+
* Takes a content entry with potentially localized field values and resolves
|
|
471
|
+
* all localized fields to single values based on the requested locale and
|
|
472
|
+
* fallback chain. This merges localized and default field values.
|
|
473
|
+
*
|
|
474
|
+
* Resolution order for each localized field:
|
|
475
|
+
* 1. Try the requested locale
|
|
476
|
+
* 2. Try each locale in the fallback chain (in order)
|
|
477
|
+
* 3. Try the default locale
|
|
478
|
+
* 4. Return first available locale as last resort
|
|
479
|
+
*
|
|
480
|
+
* @param entry - The content entry to resolve (with raw localized data)
|
|
481
|
+
* @param options - Locale resolution options
|
|
482
|
+
* @returns The entry with resolved data and metadata about resolution
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```typescript
|
|
486
|
+
* // Get an entry
|
|
487
|
+
* const entry = await cms.contentEntries.get(ctx, { id: entryId });
|
|
488
|
+
*
|
|
489
|
+
* // Resolve to Spanish with English fallback
|
|
490
|
+
* const resolved = cms.contentEntries.resolveLocale(entry, {
|
|
491
|
+
* locale: "es-ES",
|
|
492
|
+
* fallbackChain: ["en-US"],
|
|
493
|
+
* defaultLocale: "en-US",
|
|
494
|
+
* fields: contentType.fields,
|
|
495
|
+
* });
|
|
496
|
+
*
|
|
497
|
+
* // Access resolved data
|
|
498
|
+
* console.log(resolved.data.title); // "Hola" (Spanish) or "Hello" (English fallback)
|
|
499
|
+
*
|
|
500
|
+
* // Check which fields used fallback
|
|
501
|
+
* if (resolved.localeResolution.fieldsFromFallback.includes("title")) {
|
|
502
|
+
* console.log("Title was not translated to Spanish");
|
|
503
|
+
* }
|
|
504
|
+
*
|
|
505
|
+
* // See which locale each field was resolved from
|
|
506
|
+
* console.log(resolved.localeResolution.fieldResolutions);
|
|
507
|
+
* // { content: "en-US" } - content was resolved from English
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
resolveLocale(entry, options) {
|
|
511
|
+
return resolveLocaleContent(entry, options);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Resolve locale content for multiple content entries.
|
|
515
|
+
*
|
|
516
|
+
* Convenience method for batch-resolving a list of entries.
|
|
517
|
+
* Useful after fetching a paginated list of content entries.
|
|
518
|
+
*
|
|
519
|
+
* @param entries - Array of content entries to resolve
|
|
520
|
+
* @param options - Locale resolution options (applied to all entries)
|
|
521
|
+
* @returns Array of entries with resolved locale data
|
|
522
|
+
*
|
|
523
|
+
* @example
|
|
524
|
+
* ```typescript
|
|
525
|
+
* // Fetch published blog posts
|
|
526
|
+
* const { page } = await cms.contentEntries.list(ctx, {
|
|
527
|
+
* contentTypeName: "blog_post",
|
|
528
|
+
* status: "published",
|
|
529
|
+
* paginationOpts: { numItems: 10 },
|
|
530
|
+
* });
|
|
531
|
+
*
|
|
532
|
+
* // Resolve all entries to Spanish
|
|
533
|
+
* const resolvedPosts = cms.contentEntries.resolveLocaleBatch(page, {
|
|
534
|
+
* locale: "es-ES",
|
|
535
|
+
* fallbackChain: cms.getLocaleFallbackChain("es-ES"),
|
|
536
|
+
* defaultLocale: cms.config.defaultLocale,
|
|
537
|
+
* fields: blogPostType.fields,
|
|
538
|
+
* });
|
|
539
|
+
*
|
|
540
|
+
* // Use resolved data
|
|
541
|
+
* for (const post of resolvedPosts) {
|
|
542
|
+
* console.log(post.data.title); // Resolved title in Spanish or fallback
|
|
543
|
+
* }
|
|
544
|
+
* ```
|
|
545
|
+
*/
|
|
546
|
+
resolveLocaleBatch(entries, options) {
|
|
547
|
+
return resolveLocaleContentBatch(entries, options);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* List content entries with automatic locale resolution.
|
|
551
|
+
*
|
|
552
|
+
* This is a convenience method that combines `list()` and `resolveLocaleBatch()`
|
|
553
|
+
* into a single call. It fetches content entries and automatically resolves
|
|
554
|
+
* all localized fields to the requested locale with fallback support.
|
|
555
|
+
*
|
|
556
|
+
* Note: This method requires the content type's field definitions to properly
|
|
557
|
+
* resolve localized fields. You can either pass them explicitly or let the
|
|
558
|
+
* method fetch them automatically (requires an extra query).
|
|
559
|
+
*
|
|
560
|
+
* @param ctx - Convex query context
|
|
561
|
+
* @param args - Query options with pagination
|
|
562
|
+
* @param localeOptions - Locale resolution options
|
|
563
|
+
* @returns Paginated result with locale-resolved entries
|
|
564
|
+
*
|
|
565
|
+
* @example
|
|
566
|
+
* ```typescript
|
|
567
|
+
* // List with locale resolution
|
|
568
|
+
* const { page, continueCursor, isDone } = await cms.contentEntries.listWithLocale(
|
|
569
|
+
* ctx,
|
|
570
|
+
* {
|
|
571
|
+
* contentTypeName: "blog_post",
|
|
572
|
+
* status: "published",
|
|
573
|
+
* paginationOpts: { numItems: 10 },
|
|
574
|
+
* },
|
|
575
|
+
* {
|
|
576
|
+
* locale: "es-ES",
|
|
577
|
+
* fields: blogPostType.fields, // Required for resolution
|
|
578
|
+
* }
|
|
579
|
+
* );
|
|
580
|
+
*
|
|
581
|
+
* // All entries have resolved locale data
|
|
582
|
+
* for (const post of page) {
|
|
583
|
+
* console.log(post.data.title); // Resolved title
|
|
584
|
+
* console.log(post.localeResolution.fieldsFromFallback); // Which fields used fallback
|
|
585
|
+
* }
|
|
586
|
+
* ```
|
|
587
|
+
*/
|
|
588
|
+
async listWithLocale(ctx, args, localeOptions) {
|
|
589
|
+
// Fetch raw entries
|
|
590
|
+
const result = await this.list(ctx, args);
|
|
591
|
+
// Resolve locale for all entries
|
|
592
|
+
const resolvedPage = this.resolveLocaleBatch(result.page, localeOptions);
|
|
593
|
+
return {
|
|
594
|
+
page: resolvedPage,
|
|
595
|
+
continueCursor: result.continueCursor,
|
|
596
|
+
isDone: result.isDone,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Get a content entry by ID with automatic locale resolution.
|
|
601
|
+
*
|
|
602
|
+
* Fetches the entry and resolves all localized fields to the requested locale.
|
|
603
|
+
*
|
|
604
|
+
* @param ctx - Convex query context
|
|
605
|
+
* @param args - Get arguments
|
|
606
|
+
* @param localeOptions - Locale resolution options
|
|
607
|
+
* @returns The entry with resolved locale data, or null if not found
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* ```typescript
|
|
611
|
+
* const post = await cms.contentEntries.getWithLocale(
|
|
612
|
+
* ctx,
|
|
613
|
+
* { id: entryId },
|
|
614
|
+
* {
|
|
615
|
+
* locale: "es-ES",
|
|
616
|
+
* fallbackChain: ["en-US"],
|
|
617
|
+
* defaultLocale: "en-US",
|
|
618
|
+
* fields: blogPostType.fields,
|
|
619
|
+
* }
|
|
620
|
+
* );
|
|
621
|
+
*
|
|
622
|
+
* if (post) {
|
|
623
|
+
* console.log(post.data.title); // Resolved title
|
|
624
|
+
* }
|
|
625
|
+
* ```
|
|
626
|
+
*/
|
|
627
|
+
async getWithLocale(ctx, args, localeOptions) {
|
|
628
|
+
const entry = await this.get(ctx, args);
|
|
629
|
+
if (!entry)
|
|
630
|
+
return null;
|
|
631
|
+
return this.resolveLocale(entry, localeOptions);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get a content entry by slug with automatic locale resolution.
|
|
635
|
+
*
|
|
636
|
+
* Fetches the entry by slug and resolves all localized fields to the requested locale.
|
|
637
|
+
*
|
|
638
|
+
* @param ctx - Convex query context
|
|
639
|
+
* @param args - Get by slug arguments
|
|
640
|
+
* @param localeOptions - Locale resolution options
|
|
641
|
+
* @returns The entry with resolved locale data, or null if not found
|
|
642
|
+
*
|
|
643
|
+
* @example
|
|
644
|
+
* ```typescript
|
|
645
|
+
* const post = await cms.contentEntries.getBySlugWithLocale(
|
|
646
|
+
* ctx,
|
|
647
|
+
* {
|
|
648
|
+
* contentTypeName: "blog_post",
|
|
649
|
+
* slug: "hello-world",
|
|
650
|
+
* },
|
|
651
|
+
* {
|
|
652
|
+
* locale: "es-ES",
|
|
653
|
+
* fields: blogPostType.fields,
|
|
654
|
+
* }
|
|
655
|
+
* );
|
|
656
|
+
* ```
|
|
657
|
+
*/
|
|
658
|
+
async getBySlugWithLocale(ctx, args, localeOptions) {
|
|
659
|
+
const entry = await this.getBySlug(ctx, args);
|
|
660
|
+
if (!entry)
|
|
661
|
+
return null;
|
|
662
|
+
return this.resolveLocale(entry, localeOptions);
|
|
663
|
+
}
|
|
664
|
+
// ===========================================================================
|
|
665
|
+
// Duplicate Entry
|
|
666
|
+
// ===========================================================================
|
|
667
|
+
/**
|
|
668
|
+
* Duplicate a content entry.
|
|
669
|
+
*
|
|
670
|
+
* Creates a copy of an existing content entry with a new unique slug.
|
|
671
|
+
* The duplicate always starts as a draft, regardless of the source entry's status.
|
|
672
|
+
* Media references are copied by default but can be cleared.
|
|
673
|
+
*
|
|
674
|
+
* @param ctx - Convex mutation context
|
|
675
|
+
* @param args - Duplicate arguments
|
|
676
|
+
* @returns The duplicated entry
|
|
677
|
+
*
|
|
678
|
+
* @example
|
|
679
|
+
* ```typescript
|
|
680
|
+
* // Simple duplication with auto-generated slug
|
|
681
|
+
* const copy = await cms.contentEntries.duplicate(ctx, {
|
|
682
|
+
* sourceEntryId: originalPost._id,
|
|
683
|
+
* createdBy: currentUserId,
|
|
684
|
+
* });
|
|
685
|
+
*
|
|
686
|
+
* // Duplicate with custom slug
|
|
687
|
+
* const copy = await cms.contentEntries.duplicate(ctx, {
|
|
688
|
+
* sourceEntryId: templateId,
|
|
689
|
+
* slug: "new-post-from-template",
|
|
690
|
+
* createdBy: currentUserId,
|
|
691
|
+
* });
|
|
692
|
+
*
|
|
693
|
+
* // Duplicate without media references (for a fresh start)
|
|
694
|
+
* const copy = await cms.contentEntries.duplicate(ctx, {
|
|
695
|
+
* sourceEntryId: originalPost._id,
|
|
696
|
+
* copyMediaReferences: false,
|
|
697
|
+
* createdBy: currentUserId,
|
|
698
|
+
* });
|
|
699
|
+
* ```
|
|
700
|
+
*/
|
|
701
|
+
async duplicate(ctx, args) {
|
|
702
|
+
// Authorization check - duplicating is similar to create
|
|
703
|
+
await this.authorize(ctx, "contentEntries.create", args.createdBy);
|
|
704
|
+
// Rate limit check
|
|
705
|
+
await this.rateLimit(ctx, "contentEntries.create", args.createdBy);
|
|
706
|
+
return ctx.runMutation(this.api.contentEntryMutations.duplicateEntry, args);
|
|
707
|
+
}
|
|
708
|
+
// ===========================================================================
|
|
709
|
+
// Bulk Operations
|
|
710
|
+
// ===========================================================================
|
|
711
|
+
/**
|
|
712
|
+
* Publish multiple content entries in a single transaction.
|
|
713
|
+
*
|
|
714
|
+
* This is more efficient than publishing entries one by one. Each entry that
|
|
715
|
+
* is already published will be skipped (idempotent behavior). Deleted or
|
|
716
|
+
* archived entries will fail with an error message.
|
|
717
|
+
*
|
|
718
|
+
* @param ctx - Convex mutation context
|
|
719
|
+
* @param args - Bulk publish arguments
|
|
720
|
+
* @returns Bulk operation result with success/failure details for each entry
|
|
721
|
+
*
|
|
722
|
+
* @example
|
|
723
|
+
* ```typescript
|
|
724
|
+
* const result = await cms.contentEntries.bulkPublish(ctx, {
|
|
725
|
+
* ids: [entry1._id, entry2._id, entry3._id],
|
|
726
|
+
* changeDescription: "Publishing launch content",
|
|
727
|
+
* updatedBy: currentUserId,
|
|
728
|
+
* });
|
|
729
|
+
* console.log(`Published ${result.succeeded} of ${result.total} entries`);
|
|
730
|
+
* if (result.failed > 0) {
|
|
731
|
+
* result.results.filter(r => !r.success).forEach(r => {
|
|
732
|
+
* console.error(`Failed to publish ${r.id}: ${r.error}`);
|
|
733
|
+
* });
|
|
734
|
+
* }
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
async bulkPublish(ctx, args) {
|
|
738
|
+
// Authorization check for each entry (bulk check)
|
|
739
|
+
await this.authorize(ctx, "contentEntries.publish", args.updatedBy);
|
|
740
|
+
// Rate limit check
|
|
741
|
+
await this.rateLimit(ctx, "contentEntries.publish", args.updatedBy);
|
|
742
|
+
return ctx.runMutation(this.api.bulkOperations.bulkPublish, args);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Unpublish multiple content entries in a single transaction.
|
|
746
|
+
*
|
|
747
|
+
* Reverts published entries to draft status. Non-published entries are
|
|
748
|
+
* skipped (idempotent behavior).
|
|
749
|
+
*
|
|
750
|
+
* @param ctx - Convex mutation context
|
|
751
|
+
* @param args - Bulk unpublish arguments
|
|
752
|
+
* @returns Bulk operation result with success/failure details for each entry
|
|
753
|
+
*
|
|
754
|
+
* @example
|
|
755
|
+
* ```typescript
|
|
756
|
+
* const result = await cms.contentEntries.bulkUnpublish(ctx, {
|
|
757
|
+
* ids: [entry1._id, entry2._id],
|
|
758
|
+
* updatedBy: currentUserId,
|
|
759
|
+
* });
|
|
760
|
+
* ```
|
|
761
|
+
*/
|
|
762
|
+
async bulkUnpublish(ctx, args) {
|
|
763
|
+
// Authorization check
|
|
764
|
+
await this.authorize(ctx, "contentEntries.unpublish", args.updatedBy);
|
|
765
|
+
// Rate limit check
|
|
766
|
+
await this.rateLimit(ctx, "contentEntries.unpublish", args.updatedBy);
|
|
767
|
+
return ctx.runMutation(this.api.bulkOperations.bulkUnpublish, args);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Delete multiple content entries in a single transaction.
|
|
771
|
+
*
|
|
772
|
+
* By default, performs soft delete (entries can be restored later).
|
|
773
|
+
* When hardDelete is true, permanently removes entries and all their versions.
|
|
774
|
+
*
|
|
775
|
+
* @param ctx - Convex mutation context
|
|
776
|
+
* @param args - Bulk delete arguments
|
|
777
|
+
* @returns Bulk operation result with success/failure details for each entry
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* ```typescript
|
|
781
|
+
* // Soft delete (default)
|
|
782
|
+
* const result = await cms.contentEntries.bulkDelete(ctx, {
|
|
783
|
+
* ids: [entry1._id, entry2._id],
|
|
784
|
+
* deletedBy: currentUserId,
|
|
785
|
+
* });
|
|
786
|
+
*
|
|
787
|
+
* // Hard delete (permanent)
|
|
788
|
+
* const result = await cms.contentEntries.bulkDelete(ctx, {
|
|
789
|
+
* ids: [entry1._id, entry2._id],
|
|
790
|
+
* deletedBy: currentUserId,
|
|
791
|
+
* hardDelete: true,
|
|
792
|
+
* });
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
async bulkDelete(ctx, args) {
|
|
796
|
+
// Authorization check
|
|
797
|
+
await this.authorize(ctx, "contentEntries.delete", args.deletedBy);
|
|
798
|
+
// Rate limit check
|
|
799
|
+
await this.rateLimit(ctx, "contentEntries.delete", args.deletedBy);
|
|
800
|
+
return ctx.runMutation(this.api.bulkOperations.bulkDelete, args);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Update multiple content entries with the same changes in a single transaction.
|
|
804
|
+
*
|
|
805
|
+
* Applies the same data updates and/or status change to all specified entries.
|
|
806
|
+
* Data is merged with existing data for each entry (partial updates).
|
|
807
|
+
* Each entry is validated against its content type schema.
|
|
808
|
+
*
|
|
809
|
+
* @param ctx - Convex mutation context
|
|
810
|
+
* @param args - Bulk update arguments
|
|
811
|
+
* @returns Bulk operation result with success/failure details for each entry
|
|
812
|
+
*
|
|
813
|
+
* @example
|
|
814
|
+
* ```typescript
|
|
815
|
+
* // Update data for multiple entries
|
|
816
|
+
* const result = await cms.contentEntries.bulkUpdate(ctx, {
|
|
817
|
+
* ids: [entry1._id, entry2._id, entry3._id],
|
|
818
|
+
* data: { featured: true, category: "news" },
|
|
819
|
+
* updatedBy: currentUserId,
|
|
820
|
+
* });
|
|
821
|
+
*
|
|
822
|
+
* // Change status for multiple entries
|
|
823
|
+
* const result = await cms.contentEntries.bulkUpdate(ctx, {
|
|
824
|
+
* ids: [entry1._id, entry2._id],
|
|
825
|
+
* status: "archived",
|
|
826
|
+
* updatedBy: currentUserId,
|
|
827
|
+
* });
|
|
828
|
+
* ```
|
|
829
|
+
*/
|
|
830
|
+
async bulkUpdate(ctx, args) {
|
|
831
|
+
// Authorization check
|
|
832
|
+
await this.authorize(ctx, "contentEntries.update", args.updatedBy);
|
|
833
|
+
// Rate limit check
|
|
834
|
+
await this.rateLimit(ctx, "contentEntries.update", args.updatedBy);
|
|
835
|
+
return ctx.runMutation(this.api.bulkOperations.bulkUpdate, args);
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Restore multiple soft-deleted content entries in a single transaction.
|
|
839
|
+
*
|
|
840
|
+
* Removes the deletedAt marker from entries, making them active again.
|
|
841
|
+
* Only works for soft-deleted entries. Non-deleted entries are skipped
|
|
842
|
+
* (idempotent behavior).
|
|
843
|
+
*
|
|
844
|
+
* @param ctx - Convex mutation context
|
|
845
|
+
* @param args - Bulk restore arguments
|
|
846
|
+
* @returns Bulk operation result with success/failure details for each entry
|
|
847
|
+
*
|
|
848
|
+
* @example
|
|
849
|
+
* ```typescript
|
|
850
|
+
* const result = await cms.contentEntries.bulkRestore(ctx, {
|
|
851
|
+
* ids: [deletedEntry1._id, deletedEntry2._id],
|
|
852
|
+
* restoredBy: currentUserId,
|
|
853
|
+
* });
|
|
854
|
+
* ```
|
|
855
|
+
*/
|
|
856
|
+
async bulkRestore(ctx, args) {
|
|
857
|
+
if (!this.config.features.softDelete) {
|
|
858
|
+
throw new Error("Soft delete feature is not enabled");
|
|
859
|
+
}
|
|
860
|
+
// Authorization check
|
|
861
|
+
await this.authorize(ctx, "contentEntries.restore", args.restoredBy);
|
|
862
|
+
// Rate limit check
|
|
863
|
+
await this.rateLimit(ctx, "contentEntries.restore", args.restoredBy);
|
|
864
|
+
return ctx.runMutation(this.api.bulkOperations.bulkRestore, args);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// =============================================================================
|
|
868
|
+
// Versions API Wrapper
|
|
869
|
+
// =============================================================================
|
|
870
|
+
/**
|
|
871
|
+
* Wrapper for content version operations.
|
|
872
|
+
*
|
|
873
|
+
* Provides comprehensive version management including:
|
|
874
|
+
* - Version history retrieval with pagination
|
|
875
|
+
* - Getting specific versions by ID or number
|
|
876
|
+
* - Version comparison and diff generation
|
|
877
|
+
* - Rollback functionality
|
|
878
|
+
* - Finding latest and published versions
|
|
879
|
+
*
|
|
880
|
+
* @example
|
|
881
|
+
* ```typescript
|
|
882
|
+
* // Get version history for an entry
|
|
883
|
+
* const history = await cms.versions.getHistory(ctx, {
|
|
884
|
+
* entryId: entry._id,
|
|
885
|
+
* paginationOpts: { numItems: 10 },
|
|
886
|
+
* });
|
|
887
|
+
*
|
|
888
|
+
* // Compare two versions
|
|
889
|
+
* const diff = await cms.versions.compare(ctx, {
|
|
890
|
+
* entryId: entry._id,
|
|
891
|
+
* fromVersionNumber: 1,
|
|
892
|
+
* toVersionNumber: 5,
|
|
893
|
+
* });
|
|
894
|
+
*
|
|
895
|
+
* // Rollback to a previous version
|
|
896
|
+
* await cms.versions.rollback(ctx, {
|
|
897
|
+
* entryId: entry._id,
|
|
898
|
+
* versionNumber: 3,
|
|
899
|
+
* });
|
|
900
|
+
* ```
|
|
901
|
+
*/
|
|
902
|
+
export class VersionsApi {
|
|
903
|
+
api;
|
|
904
|
+
config;
|
|
905
|
+
authHelper;
|
|
906
|
+
rateLimitHelper;
|
|
907
|
+
constructor(api, config, authHelper, rateLimitHelper) {
|
|
908
|
+
this.api = api;
|
|
909
|
+
this.config = config;
|
|
910
|
+
this.authHelper = authHelper;
|
|
911
|
+
this.rateLimitHelper = rateLimitHelper;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Check if versioning feature is enabled.
|
|
915
|
+
* @throws Error if versioning is not enabled
|
|
916
|
+
*/
|
|
917
|
+
ensureVersioningEnabled() {
|
|
918
|
+
if (!this.config.features.versioning) {
|
|
919
|
+
throw new Error("Versioning feature is not enabled");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Perform authorization check for version operations.
|
|
924
|
+
* @param ctx - The Convex context (passed to authorization hooks for database access)
|
|
925
|
+
* @param operation - The CMS operation being performed
|
|
926
|
+
* @param userId - The user performing the operation
|
|
927
|
+
* @param resourceId - Optional resource ID (entry ID for version operations)
|
|
928
|
+
* @throws AuthorizationNotConfiguredError if authorization is not configured and permissiveMode is false
|
|
929
|
+
*/
|
|
930
|
+
async authorize(ctx, operation, userId, resourceId) {
|
|
931
|
+
if (!this.authHelper) {
|
|
932
|
+
if (this.config.permissiveMode) {
|
|
933
|
+
console.warn(`[ConvexCMS] Authorization not configured for "${operation}". ` +
|
|
934
|
+
"Operations are allowed in permissiveMode, but this should NOT be used in production.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
throw new AuthorizationNotConfiguredError(operation);
|
|
938
|
+
}
|
|
939
|
+
if (this.authHelper.skipRbac) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (!userId) {
|
|
943
|
+
if (this.config.permissiveMode) {
|
|
944
|
+
console.warn(`[ConvexCMS] Anonymous operation attempted for "${operation}".`);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
throw new AuthorizationNotConfiguredError(`${operation} (no userId provided - anonymous operations require permissiveMode)`);
|
|
948
|
+
}
|
|
949
|
+
const role = await this.authHelper.getUserRole(ctx, userId);
|
|
950
|
+
await this.authHelper.requireAuthorization(ctx, {
|
|
951
|
+
operation,
|
|
952
|
+
userId,
|
|
953
|
+
role,
|
|
954
|
+
resourceId,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Enforce rate limit for version operations.
|
|
959
|
+
* @param ctx - The Convex context (for database access)
|
|
960
|
+
* @param operation - The CMS operation being performed
|
|
961
|
+
* @param userId - The user performing the operation
|
|
962
|
+
*/
|
|
963
|
+
async rateLimit(ctx, operation, userId) {
|
|
964
|
+
// Skip if no rate limit helper configured
|
|
965
|
+
if (!this.rateLimitHelper) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
|
|
969
|
+
await this.rateLimitHelper.requireRateLimit(operation, {
|
|
970
|
+
userId,
|
|
971
|
+
role,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Get version history with standard Convex pagination.
|
|
976
|
+
*
|
|
977
|
+
* Returns versions in reverse chronological order (newest first).
|
|
978
|
+
* Compatible with `usePaginatedQuery` React hook.
|
|
979
|
+
*
|
|
980
|
+
* @param ctx - Convex query context
|
|
981
|
+
* @param args - History query arguments with pagination
|
|
982
|
+
* @returns Paginated version history or null if entry not found
|
|
983
|
+
*
|
|
984
|
+
* @example
|
|
985
|
+
* ```typescript
|
|
986
|
+
* // Get first page of version history
|
|
987
|
+
* const { page, continueCursor, isDone } = await cms.versions.getHistory(ctx, {
|
|
988
|
+
* entryId: entry._id,
|
|
989
|
+
* paginationOpts: { numItems: 10 },
|
|
990
|
+
* });
|
|
991
|
+
*
|
|
992
|
+
* // Get next page
|
|
993
|
+
* if (!isDone && continueCursor) {
|
|
994
|
+
* const nextPage = await cms.versions.getHistory(ctx, {
|
|
995
|
+
* entryId: entry._id,
|
|
996
|
+
* paginationOpts: { numItems: 10, cursor: continueCursor },
|
|
997
|
+
* });
|
|
998
|
+
* }
|
|
999
|
+
* ```
|
|
1000
|
+
*/
|
|
1001
|
+
async getHistory(ctx, args) {
|
|
1002
|
+
this.ensureVersioningEnabled();
|
|
1003
|
+
return ctx.runQuery(this.api.contentEntries.getVersionHistory, args);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Get a specific version by ID or version number.
|
|
1007
|
+
*
|
|
1008
|
+
* @param ctx - Convex query context
|
|
1009
|
+
* @param args - Get arguments (entryId required, plus versionId or versionNumber)
|
|
1010
|
+
* @returns The version or null if not found
|
|
1011
|
+
*
|
|
1012
|
+
* @example
|
|
1013
|
+
* ```typescript
|
|
1014
|
+
* // Get by version number
|
|
1015
|
+
* const version = await cms.versions.get(ctx, {
|
|
1016
|
+
* entryId: entry._id,
|
|
1017
|
+
* versionNumber: 3,
|
|
1018
|
+
* });
|
|
1019
|
+
*
|
|
1020
|
+
* // Get by version ID
|
|
1021
|
+
* const version = await cms.versions.get(ctx, {
|
|
1022
|
+
* entryId: entry._id,
|
|
1023
|
+
* versionId: "abc123",
|
|
1024
|
+
* });
|
|
1025
|
+
* ```
|
|
1026
|
+
*/
|
|
1027
|
+
async get(ctx, args) {
|
|
1028
|
+
this.ensureVersioningEnabled();
|
|
1029
|
+
return ctx.runQuery(this.api.contentEntries.getVersion, args);
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Get a version by its version number (convenience method).
|
|
1033
|
+
*
|
|
1034
|
+
* @param ctx - Convex query context
|
|
1035
|
+
* @param entryId - The content entry ID
|
|
1036
|
+
* @param versionNumber - The version number to retrieve
|
|
1037
|
+
* @returns The version or null if not found
|
|
1038
|
+
*
|
|
1039
|
+
* @example
|
|
1040
|
+
* ```typescript
|
|
1041
|
+
* const version3 = await cms.versions.getByNumber(ctx, entry._id, 3);
|
|
1042
|
+
* ```
|
|
1043
|
+
*/
|
|
1044
|
+
async getByNumber(ctx, entryId, versionNumber) {
|
|
1045
|
+
return this.get(ctx, { entryId, versionNumber });
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Get a version by its document ID (convenience method).
|
|
1049
|
+
*
|
|
1050
|
+
* @param ctx - Convex query context
|
|
1051
|
+
* @param entryId - The content entry ID
|
|
1052
|
+
* @param versionId - The version document ID
|
|
1053
|
+
* @returns The version or null if not found
|
|
1054
|
+
*
|
|
1055
|
+
* @example
|
|
1056
|
+
* ```typescript
|
|
1057
|
+
* const version = await cms.versions.getById(ctx, entry._id, versionDocId);
|
|
1058
|
+
* ```
|
|
1059
|
+
*/
|
|
1060
|
+
async getById(ctx, entryId, versionId) {
|
|
1061
|
+
return this.get(ctx, { entryId, versionId });
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Get the latest (most recent) version snapshot for an entry.
|
|
1065
|
+
*
|
|
1066
|
+
* @param ctx - Convex query context
|
|
1067
|
+
* @param entryId - The content entry ID
|
|
1068
|
+
* @returns The latest version or null if no versions exist
|
|
1069
|
+
*
|
|
1070
|
+
* @example
|
|
1071
|
+
* ```typescript
|
|
1072
|
+
* const latest = await cms.versions.getLatest(ctx, entry._id);
|
|
1073
|
+
* console.log(`Current version: ${latest?.versionNumber}`);
|
|
1074
|
+
* ```
|
|
1075
|
+
*/
|
|
1076
|
+
async getLatest(ctx, entryId) {
|
|
1077
|
+
this.ensureVersioningEnabled();
|
|
1078
|
+
const history = await this.getHistory(ctx, {
|
|
1079
|
+
entryId,
|
|
1080
|
+
paginationOpts: { numItems: 1, cursor: null },
|
|
1081
|
+
});
|
|
1082
|
+
return history?.page[0] ?? null;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Get the latest published version for an entry.
|
|
1086
|
+
*
|
|
1087
|
+
* Searches through version history to find the most recent version
|
|
1088
|
+
* that was published (wasPublished = true).
|
|
1089
|
+
*
|
|
1090
|
+
* @param ctx - Convex query context
|
|
1091
|
+
* @param entryId - The content entry ID
|
|
1092
|
+
* @returns The latest published version or null if none published
|
|
1093
|
+
*
|
|
1094
|
+
* @example
|
|
1095
|
+
* ```typescript
|
|
1096
|
+
* const published = await cms.versions.getLatestPublished(ctx, entry._id);
|
|
1097
|
+
* if (published) {
|
|
1098
|
+
* console.log(`Published at: ${new Date(published.publishedAt!)}`);
|
|
1099
|
+
* }
|
|
1100
|
+
* ```
|
|
1101
|
+
*/
|
|
1102
|
+
async getLatestPublished(ctx, entryId) {
|
|
1103
|
+
this.ensureVersioningEnabled();
|
|
1104
|
+
// Iterate through pages to find the first published version
|
|
1105
|
+
let cursor = null;
|
|
1106
|
+
let isDone = false;
|
|
1107
|
+
while (!isDone) {
|
|
1108
|
+
const history = await this.getHistory(ctx, {
|
|
1109
|
+
entryId,
|
|
1110
|
+
paginationOpts: { numItems: 50, cursor },
|
|
1111
|
+
});
|
|
1112
|
+
if (!history)
|
|
1113
|
+
return null;
|
|
1114
|
+
const publishedVersion = history.page.find((v) => v.wasPublished);
|
|
1115
|
+
if (publishedVersion) {
|
|
1116
|
+
return publishedVersion;
|
|
1117
|
+
}
|
|
1118
|
+
cursor = history.continueCursor;
|
|
1119
|
+
isDone = history.isDone;
|
|
1120
|
+
}
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Get all published versions for an entry.
|
|
1125
|
+
*
|
|
1126
|
+
* @param ctx - Convex query context
|
|
1127
|
+
* @param entryId - The content entry ID
|
|
1128
|
+
* @param limit - Maximum number of published versions to return (default: 10)
|
|
1129
|
+
* @returns Array of published versions (newest first)
|
|
1130
|
+
*
|
|
1131
|
+
* @example
|
|
1132
|
+
* ```typescript
|
|
1133
|
+
* const publishedVersions = await cms.versions.getPublishedHistory(ctx, entry._id, 5);
|
|
1134
|
+
* console.log(`Found ${publishedVersions.length} published versions`);
|
|
1135
|
+
* ```
|
|
1136
|
+
*/
|
|
1137
|
+
async getPublishedHistory(ctx, entryId, limit = 10) {
|
|
1138
|
+
this.ensureVersioningEnabled();
|
|
1139
|
+
const published = [];
|
|
1140
|
+
let cursor = null;
|
|
1141
|
+
let isDone = false;
|
|
1142
|
+
while (!isDone && published.length < limit) {
|
|
1143
|
+
const history = await this.getHistory(ctx, {
|
|
1144
|
+
entryId,
|
|
1145
|
+
paginationOpts: { numItems: 50, cursor },
|
|
1146
|
+
});
|
|
1147
|
+
if (!history)
|
|
1148
|
+
break;
|
|
1149
|
+
for (const version of history.page) {
|
|
1150
|
+
if (version.wasPublished) {
|
|
1151
|
+
published.push(version);
|
|
1152
|
+
if (published.length >= limit)
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
cursor = history.continueCursor;
|
|
1157
|
+
isDone = history.isDone;
|
|
1158
|
+
}
|
|
1159
|
+
return published;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Compare two versions and generate a detailed diff.
|
|
1163
|
+
*
|
|
1164
|
+
* Analyzes field-level changes between two versions, identifying:
|
|
1165
|
+
* - Added fields (present in toVersion but not fromVersion)
|
|
1166
|
+
* - Removed fields (present in fromVersion but not toVersion)
|
|
1167
|
+
* - Modified fields (present in both but with different values)
|
|
1168
|
+
*
|
|
1169
|
+
* @param ctx - Convex query context
|
|
1170
|
+
* @param args - Comparison arguments
|
|
1171
|
+
* @returns Detailed version comparison or null if versions not found
|
|
1172
|
+
*
|
|
1173
|
+
* @example
|
|
1174
|
+
* ```typescript
|
|
1175
|
+
* const diff = await cms.versions.compare(ctx, {
|
|
1176
|
+
* entryId: entry._id,
|
|
1177
|
+
* fromVersionNumber: 1,
|
|
1178
|
+
* toVersionNumber: 5,
|
|
1179
|
+
* });
|
|
1180
|
+
*
|
|
1181
|
+
* if (diff) {
|
|
1182
|
+
* console.log(`${diff.summary.totalChanges} changes detected`);
|
|
1183
|
+
* for (const change of diff.changes) {
|
|
1184
|
+
* console.log(`${change.field}: ${change.changeType}`);
|
|
1185
|
+
* }
|
|
1186
|
+
* }
|
|
1187
|
+
* ```
|
|
1188
|
+
*/
|
|
1189
|
+
async compare(ctx, args) {
|
|
1190
|
+
this.ensureVersioningEnabled();
|
|
1191
|
+
// Get the fromVersion
|
|
1192
|
+
const fromVersion = await this.getByNumber(ctx, args.entryId, args.fromVersionNumber);
|
|
1193
|
+
if (!fromVersion)
|
|
1194
|
+
return null;
|
|
1195
|
+
// Get the toVersion
|
|
1196
|
+
const toVersion = await this.getByNumber(ctx, args.entryId, args.toVersionNumber);
|
|
1197
|
+
if (!toVersion)
|
|
1198
|
+
return null;
|
|
1199
|
+
// Generate the comparison
|
|
1200
|
+
return this.generateComparison(fromVersion, toVersion);
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Compare the current entry state with a specific version.
|
|
1204
|
+
*
|
|
1205
|
+
* Useful for seeing what has changed since a particular point in time.
|
|
1206
|
+
*
|
|
1207
|
+
* @param ctx - Convex query context
|
|
1208
|
+
* @param entryId - The content entry ID
|
|
1209
|
+
* @param versionNumber - The version number to compare against
|
|
1210
|
+
* @returns Comparison between the version and current state, or null
|
|
1211
|
+
*
|
|
1212
|
+
* @example
|
|
1213
|
+
* ```typescript
|
|
1214
|
+
* // See what changed since version 3
|
|
1215
|
+
* const diff = await cms.versions.compareWithCurrent(ctx, entry._id, 3);
|
|
1216
|
+
* ```
|
|
1217
|
+
*/
|
|
1218
|
+
async compareWithCurrent(ctx, entryId, versionNumber) {
|
|
1219
|
+
this.ensureVersioningEnabled();
|
|
1220
|
+
const fromVersion = await this.getByNumber(ctx, entryId, versionNumber);
|
|
1221
|
+
if (!fromVersion)
|
|
1222
|
+
return null;
|
|
1223
|
+
const latest = await this.getLatest(ctx, entryId);
|
|
1224
|
+
if (!latest)
|
|
1225
|
+
return null;
|
|
1226
|
+
return this.generateComparison(fromVersion, latest);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Check if a specific version exists.
|
|
1230
|
+
*
|
|
1231
|
+
* @param ctx - Convex query context
|
|
1232
|
+
* @param entryId - The content entry ID
|
|
1233
|
+
* @param versionNumber - The version number to check
|
|
1234
|
+
* @returns true if the version exists
|
|
1235
|
+
*
|
|
1236
|
+
* @example
|
|
1237
|
+
* ```typescript
|
|
1238
|
+
* if (await cms.versions.exists(ctx, entry._id, 5)) {
|
|
1239
|
+
* // Version 5 exists
|
|
1240
|
+
* }
|
|
1241
|
+
* ```
|
|
1242
|
+
*/
|
|
1243
|
+
async exists(ctx, entryId, versionNumber) {
|
|
1244
|
+
const version = await this.getByNumber(ctx, entryId, versionNumber);
|
|
1245
|
+
return version !== null;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Count total number of versions for an entry.
|
|
1249
|
+
*
|
|
1250
|
+
* @param ctx - Convex query context
|
|
1251
|
+
* @param entryId - The content entry ID
|
|
1252
|
+
* @returns Total number of version snapshots
|
|
1253
|
+
*
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```typescript
|
|
1256
|
+
* const count = await cms.versions.count(ctx, entry._id);
|
|
1257
|
+
* console.log(`Entry has ${count} versions`);
|
|
1258
|
+
* ```
|
|
1259
|
+
*/
|
|
1260
|
+
async count(ctx, entryId) {
|
|
1261
|
+
this.ensureVersioningEnabled();
|
|
1262
|
+
let total = 0;
|
|
1263
|
+
let cursor = null;
|
|
1264
|
+
let isDone = false;
|
|
1265
|
+
while (!isDone) {
|
|
1266
|
+
const history = await this.getHistory(ctx, {
|
|
1267
|
+
entryId,
|
|
1268
|
+
paginationOpts: { numItems: 100, cursor },
|
|
1269
|
+
});
|
|
1270
|
+
if (!history)
|
|
1271
|
+
break;
|
|
1272
|
+
total += history.page.length;
|
|
1273
|
+
cursor = history.continueCursor;
|
|
1274
|
+
isDone = history.isDone;
|
|
1275
|
+
}
|
|
1276
|
+
return total;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Rollback a content entry to a previous version.
|
|
1280
|
+
*
|
|
1281
|
+
* This is a non-destructive operation that:
|
|
1282
|
+
* 1. Creates a snapshot of the current state (for undo capability)
|
|
1283
|
+
* 2. Restores data and slug from the target version
|
|
1284
|
+
* 3. Increments the version number
|
|
1285
|
+
* 4. Creates a new snapshot documenting the rollback
|
|
1286
|
+
*
|
|
1287
|
+
* The entry's status, scheduled publish time, and publishing timestamps
|
|
1288
|
+
* are preserved (not restored from the target version).
|
|
1289
|
+
*
|
|
1290
|
+
* @param ctx - Convex mutation context
|
|
1291
|
+
* @param args - Rollback arguments
|
|
1292
|
+
* @returns The updated entry with rolled back content
|
|
1293
|
+
*
|
|
1294
|
+
* @example
|
|
1295
|
+
* ```typescript
|
|
1296
|
+
* // Rollback to version 3
|
|
1297
|
+
* const entry = await cms.versions.rollback(ctx, {
|
|
1298
|
+
* entryId: entry._id,
|
|
1299
|
+
* versionNumber: 3,
|
|
1300
|
+
* updatedBy: currentUserId,
|
|
1301
|
+
* });
|
|
1302
|
+
*
|
|
1303
|
+
* // The entry is now at a new version number (e.g., 7)
|
|
1304
|
+
* // but with content from version 3
|
|
1305
|
+
* console.log(`Rolled back, now at version ${entry.version}`);
|
|
1306
|
+
* ```
|
|
1307
|
+
*/
|
|
1308
|
+
async rollback(ctx, args) {
|
|
1309
|
+
this.ensureVersioningEnabled();
|
|
1310
|
+
// Authorization check - versions.rollback
|
|
1311
|
+
await this.authorize(ctx, "versions.rollback", args.updatedBy, args.entryId);
|
|
1312
|
+
// Rate limit check - versions.rollback
|
|
1313
|
+
await this.rateLimit(ctx, "versions.rollback", args.updatedBy);
|
|
1314
|
+
return ctx.runMutation(this.api.versionMutations.rollbackVersion, args);
|
|
1315
|
+
}
|
|
1316
|
+
// =========================================================================
|
|
1317
|
+
// Private Helper Methods
|
|
1318
|
+
// =========================================================================
|
|
1319
|
+
/**
|
|
1320
|
+
* Generate a detailed comparison between two versions.
|
|
1321
|
+
*/
|
|
1322
|
+
generateComparison(fromVersion, toVersion) {
|
|
1323
|
+
const changes = [];
|
|
1324
|
+
const fromData = fromVersion.data;
|
|
1325
|
+
const toData = toVersion.data;
|
|
1326
|
+
// Get all unique field names from both versions
|
|
1327
|
+
const allFields = new Set([
|
|
1328
|
+
...Object.keys(fromData),
|
|
1329
|
+
...Object.keys(toData),
|
|
1330
|
+
]);
|
|
1331
|
+
let fieldsAdded = 0;
|
|
1332
|
+
let fieldsRemoved = 0;
|
|
1333
|
+
let fieldsModified = 0;
|
|
1334
|
+
for (const field of allFields) {
|
|
1335
|
+
const oldValue = fromData[field];
|
|
1336
|
+
const newValue = toData[field];
|
|
1337
|
+
const inOld = field in fromData;
|
|
1338
|
+
const inNew = field in toData;
|
|
1339
|
+
let changeType;
|
|
1340
|
+
if (!inOld && inNew) {
|
|
1341
|
+
changeType = "added";
|
|
1342
|
+
fieldsAdded++;
|
|
1343
|
+
}
|
|
1344
|
+
else if (inOld && !inNew) {
|
|
1345
|
+
changeType = "removed";
|
|
1346
|
+
fieldsRemoved++;
|
|
1347
|
+
}
|
|
1348
|
+
else if (!this.deepEqual(oldValue, newValue)) {
|
|
1349
|
+
changeType = "modified";
|
|
1350
|
+
fieldsModified++;
|
|
1351
|
+
}
|
|
1352
|
+
else {
|
|
1353
|
+
changeType = "unchanged";
|
|
1354
|
+
}
|
|
1355
|
+
// Only include changes (skip unchanged fields)
|
|
1356
|
+
if (changeType !== "unchanged") {
|
|
1357
|
+
changes.push({
|
|
1358
|
+
field,
|
|
1359
|
+
changeType,
|
|
1360
|
+
oldValue: inOld ? oldValue : undefined,
|
|
1361
|
+
newValue: inNew ? newValue : undefined,
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
fromVersion,
|
|
1367
|
+
toVersion,
|
|
1368
|
+
changes,
|
|
1369
|
+
slugChanged: fromVersion.slug !== toVersion.slug,
|
|
1370
|
+
statusChanged: fromVersion.status !== toVersion.status,
|
|
1371
|
+
summary: {
|
|
1372
|
+
fieldsAdded,
|
|
1373
|
+
fieldsRemoved,
|
|
1374
|
+
fieldsModified,
|
|
1375
|
+
totalChanges: fieldsAdded + fieldsRemoved + fieldsModified,
|
|
1376
|
+
},
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Deep equality check for comparing field values.
|
|
1381
|
+
*/
|
|
1382
|
+
deepEqual(a, b) {
|
|
1383
|
+
if (a === b)
|
|
1384
|
+
return true;
|
|
1385
|
+
if (a === null || b === null)
|
|
1386
|
+
return false;
|
|
1387
|
+
if (typeof a !== typeof b)
|
|
1388
|
+
return false;
|
|
1389
|
+
if (typeof a === "object") {
|
|
1390
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1391
|
+
if (a.length !== b.length)
|
|
1392
|
+
return false;
|
|
1393
|
+
return a.every((item, index) => this.deepEqual(item, b[index]));
|
|
1394
|
+
}
|
|
1395
|
+
if (!Array.isArray(a) && !Array.isArray(b)) {
|
|
1396
|
+
const aObj = a;
|
|
1397
|
+
const bObj = b;
|
|
1398
|
+
const aKeys = Object.keys(aObj);
|
|
1399
|
+
const bKeys = Object.keys(bObj);
|
|
1400
|
+
if (aKeys.length !== bKeys.length)
|
|
1401
|
+
return false;
|
|
1402
|
+
return aKeys.every((key) => this.deepEqual(aObj[key], bObj[key]));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// =============================================================================
|
|
1409
|
+
// Media Assets API Wrapper
|
|
1410
|
+
// =============================================================================
|
|
1411
|
+
/**
|
|
1412
|
+
* Wrapper for media asset operations.
|
|
1413
|
+
*/
|
|
1414
|
+
export class MediaAssetsApi {
|
|
1415
|
+
api;
|
|
1416
|
+
config;
|
|
1417
|
+
authHelper;
|
|
1418
|
+
rateLimitHelper;
|
|
1419
|
+
constructor(api, config, authHelper, rateLimitHelper) {
|
|
1420
|
+
this.api = api;
|
|
1421
|
+
this.config = config;
|
|
1422
|
+
this.authHelper = authHelper;
|
|
1423
|
+
this.rateLimitHelper = rateLimitHelper;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Perform authorization check for media asset operations.
|
|
1427
|
+
* @param ctx - The Convex context (passed to authorization hooks for database access)
|
|
1428
|
+
* @param operation - The CMS operation being performed
|
|
1429
|
+
* @param userId - The user performing the operation
|
|
1430
|
+
* @param resourceId - Optional resource ID (for update/delete operations)
|
|
1431
|
+
* @param resourceOwnerId - Optional owner ID for ownership-based permissions
|
|
1432
|
+
* @throws AuthorizationNotConfiguredError if authorization is not configured and permissiveMode is false
|
|
1433
|
+
*/
|
|
1434
|
+
async authorize(ctx, operation, userId, resourceId, resourceOwnerId) {
|
|
1435
|
+
if (!this.authHelper) {
|
|
1436
|
+
if (this.config.permissiveMode) {
|
|
1437
|
+
console.warn(`[ConvexCMS] Authorization not configured for "${operation}". ` +
|
|
1438
|
+
"Operations are allowed in permissiveMode, but this should NOT be used in production.");
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
throw new AuthorizationNotConfiguredError(operation);
|
|
1442
|
+
}
|
|
1443
|
+
if (this.authHelper.skipRbac) {
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (!userId) {
|
|
1447
|
+
if (this.config.permissiveMode) {
|
|
1448
|
+
console.warn(`[ConvexCMS] Anonymous operation attempted for "${operation}".`);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
throw new AuthorizationNotConfiguredError(`${operation} (no userId provided - anonymous operations require permissiveMode)`);
|
|
1452
|
+
}
|
|
1453
|
+
const role = await this.authHelper.getUserRole(ctx, userId);
|
|
1454
|
+
await this.authHelper.requireAuthorization(ctx, {
|
|
1455
|
+
operation,
|
|
1456
|
+
userId,
|
|
1457
|
+
role,
|
|
1458
|
+
resourceId,
|
|
1459
|
+
resourceOwnerId,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Enforce rate limit for media asset operations.
|
|
1464
|
+
* @param ctx - The Convex context (for database access)
|
|
1465
|
+
* @param operation - The CMS operation being performed
|
|
1466
|
+
* @param userId - The user performing the operation
|
|
1467
|
+
*/
|
|
1468
|
+
async rateLimit(ctx, operation, userId) {
|
|
1469
|
+
// Skip if no rate limit helper configured
|
|
1470
|
+
if (!this.rateLimitHelper) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
|
|
1474
|
+
await this.rateLimitHelper.requireRateLimit(operation, {
|
|
1475
|
+
userId,
|
|
1476
|
+
role,
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Create a new media asset record.
|
|
1481
|
+
*
|
|
1482
|
+
* @param ctx - Convex mutation context
|
|
1483
|
+
* @param args - Asset creation arguments
|
|
1484
|
+
* @returns The created asset
|
|
1485
|
+
*
|
|
1486
|
+
* @example
|
|
1487
|
+
* ```typescript
|
|
1488
|
+
* // After uploading to Convex storage
|
|
1489
|
+
* const asset = await cms.mediaAssets.create(ctx, {
|
|
1490
|
+
* storageId: storageId,
|
|
1491
|
+
* filename: "photo.jpg",
|
|
1492
|
+
* mimeType: "image/jpeg",
|
|
1493
|
+
* size: 102400,
|
|
1494
|
+
* type: "image",
|
|
1495
|
+
* width: 1920,
|
|
1496
|
+
* height: 1080,
|
|
1497
|
+
* });
|
|
1498
|
+
* ```
|
|
1499
|
+
*/
|
|
1500
|
+
async create(ctx, args) {
|
|
1501
|
+
if (!this.config.features.mediaManagement) {
|
|
1502
|
+
throw new Error("Media management feature is not enabled");
|
|
1503
|
+
}
|
|
1504
|
+
// Authorization check - mediaAssets.create
|
|
1505
|
+
await this.authorize(ctx, "mediaItems.create", args.createdBy);
|
|
1506
|
+
// Rate limit check - mediaAssets.create (media uploads are high-frequency operations)
|
|
1507
|
+
await this.rateLimit(ctx, "mediaItems.create", args.createdBy);
|
|
1508
|
+
// Validate file size
|
|
1509
|
+
if (args.size && args.size > this.config.maxMediaFileSize) {
|
|
1510
|
+
throw new Error(`File size ${args.size} exceeds maximum allowed size of ${this.config.maxMediaFileSize} bytes`);
|
|
1511
|
+
}
|
|
1512
|
+
// Cast safe: createMediaAsset always returns kind="asset"
|
|
1513
|
+
return ctx.runMutation(this.api.mediaAssetMutations.createMediaAsset, args);
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Update media asset metadata.
|
|
1517
|
+
*
|
|
1518
|
+
* @param ctx - Convex mutation context
|
|
1519
|
+
* @param args - Asset update arguments
|
|
1520
|
+
* @returns The updated asset
|
|
1521
|
+
*/
|
|
1522
|
+
async update(ctx, args) {
|
|
1523
|
+
if (!this.config.features.mediaManagement) {
|
|
1524
|
+
throw new Error("Media management feature is not enabled");
|
|
1525
|
+
}
|
|
1526
|
+
// Fetch asset for ownership-based authorization
|
|
1527
|
+
const asset = await ctx.runQuery(this.api.mediaAssets.get, { id: args.id });
|
|
1528
|
+
if (!asset) {
|
|
1529
|
+
throw new Error(`Media asset not found: ${args.id}`);
|
|
1530
|
+
}
|
|
1531
|
+
// Authorization check - mediaAssets.update (with ownership info)
|
|
1532
|
+
await this.authorize(ctx, "mediaItems.update", args.updatedBy, args.id, asset.createdBy);
|
|
1533
|
+
// Rate limit check - mediaAssets.update
|
|
1534
|
+
await this.rateLimit(ctx, "mediaItems.update", args.updatedBy);
|
|
1535
|
+
// Cast safe: updateMediaAsset always returns kind="asset"
|
|
1536
|
+
return ctx.runMutation(this.api.mediaAssetMutations.updateMediaAsset, args);
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Soft delete a media asset.
|
|
1540
|
+
*
|
|
1541
|
+
* @param ctx - Convex mutation context
|
|
1542
|
+
* @param args - Delete arguments
|
|
1543
|
+
* @returns The deleted asset
|
|
1544
|
+
*/
|
|
1545
|
+
async delete(ctx, args) {
|
|
1546
|
+
if (!this.config.features.mediaManagement) {
|
|
1547
|
+
throw new Error("Media management feature is not enabled");
|
|
1548
|
+
}
|
|
1549
|
+
// Fetch asset for ownership-based authorization
|
|
1550
|
+
const asset = await ctx.runQuery(this.api.mediaAssets.get, { id: args.id });
|
|
1551
|
+
if (!asset) {
|
|
1552
|
+
throw new Error(`Media asset not found: ${args.id}`);
|
|
1553
|
+
}
|
|
1554
|
+
// Authorization check - mediaAssets.delete (with ownership info)
|
|
1555
|
+
await this.authorize(ctx, "mediaItems.delete", args.deletedBy, args.id, asset.createdBy);
|
|
1556
|
+
// Rate limit check - mediaAssets.delete
|
|
1557
|
+
await this.rateLimit(ctx, "mediaItems.delete", args.deletedBy);
|
|
1558
|
+
// Cast safe: deleteMediaAsset always returns kind="asset"
|
|
1559
|
+
return ctx.runMutation(this.api.mediaAssetMutations.deleteMediaAsset, args);
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Get a media asset by ID.
|
|
1563
|
+
*
|
|
1564
|
+
* @param ctx - Convex query context
|
|
1565
|
+
* @param args - Get arguments
|
|
1566
|
+
* @returns The asset or null if not found
|
|
1567
|
+
*/
|
|
1568
|
+
async get(ctx, args) {
|
|
1569
|
+
if (!this.config.features.mediaManagement) {
|
|
1570
|
+
throw new Error("Media management feature is not enabled");
|
|
1571
|
+
}
|
|
1572
|
+
// Cast safe: mediaAssets.get filters for kind="asset"
|
|
1573
|
+
return ctx.runQuery(this.api.mediaAssets.get, args);
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* List media assets with optional filters.
|
|
1577
|
+
*
|
|
1578
|
+
* @param ctx - Convex query context
|
|
1579
|
+
* @param args - Query options
|
|
1580
|
+
* @returns Paginated list of assets
|
|
1581
|
+
*/
|
|
1582
|
+
async list(ctx, args = {}) {
|
|
1583
|
+
if (!this.config.features.mediaManagement) {
|
|
1584
|
+
throw new Error("Media management feature is not enabled");
|
|
1585
|
+
}
|
|
1586
|
+
return await callQuery(ctx, this.api.mediaAssets.list, args);
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Generate a temporary upload URL for client-side file uploads.
|
|
1590
|
+
*
|
|
1591
|
+
* The upload flow works as follows:
|
|
1592
|
+
* 1. Call this method to get a temporary upload URL
|
|
1593
|
+
* 2. POST the file to the URL with Content-Type header set to the file's MIME type
|
|
1594
|
+
* 3. The response contains a `storageId` that references the uploaded file
|
|
1595
|
+
* 4. Call create() to save metadata and link the storageId
|
|
1596
|
+
*
|
|
1597
|
+
* @param ctx - Convex mutation context
|
|
1598
|
+
* @param args - Upload configuration options
|
|
1599
|
+
* @returns Upload URL and constraints
|
|
1600
|
+
*
|
|
1601
|
+
* @example
|
|
1602
|
+
* ```typescript
|
|
1603
|
+
* // Generate URL for image uploads
|
|
1604
|
+
* const { uploadUrl, expiresAt, maxFileSize } = await cms.mediaAssets.generateUploadUrl(ctx, {
|
|
1605
|
+
* maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
1606
|
+
* allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
|
1607
|
+
* });
|
|
1608
|
+
*
|
|
1609
|
+
* // Client-side upload:
|
|
1610
|
+
* const response = await fetch(uploadUrl, {
|
|
1611
|
+
* method: "POST",
|
|
1612
|
+
* headers: { "Content-Type": file.type },
|
|
1613
|
+
* body: file,
|
|
1614
|
+
* });
|
|
1615
|
+
* const { storageId } = await response.json();
|
|
1616
|
+
*
|
|
1617
|
+
* // Then save metadata
|
|
1618
|
+
* const asset = await cms.mediaAssets.create(ctx, {
|
|
1619
|
+
* storageId,
|
|
1620
|
+
* filename: file.name,
|
|
1621
|
+
* mimeType: file.type,
|
|
1622
|
+
* size: file.size,
|
|
1623
|
+
* type: "image",
|
|
1624
|
+
* });
|
|
1625
|
+
* ```
|
|
1626
|
+
*/
|
|
1627
|
+
async generateUploadUrl(ctx, args = {}) {
|
|
1628
|
+
if (!this.config.features.mediaManagement) {
|
|
1629
|
+
throw new Error("Media management feature is not enabled");
|
|
1630
|
+
}
|
|
1631
|
+
// Rate limit check - mediaAssets.create (upload URL generation precedes asset creation)
|
|
1632
|
+
await this.rateLimit(ctx, "mediaItems.create", args.requestedBy);
|
|
1633
|
+
return ctx.runMutation(this.api.mediaUploadMutations.generateUploadUrl, args);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Restore a soft-deleted media asset.
|
|
1637
|
+
*
|
|
1638
|
+
* @param ctx - Convex mutation context
|
|
1639
|
+
* @param args - Restore arguments
|
|
1640
|
+
* @returns The restored asset
|
|
1641
|
+
*
|
|
1642
|
+
* @example
|
|
1643
|
+
* ```typescript
|
|
1644
|
+
* // Restore a previously deleted asset
|
|
1645
|
+
* const restoredAsset = await cms.mediaAssets.restore(ctx, {
|
|
1646
|
+
* id: assetId,
|
|
1647
|
+
* });
|
|
1648
|
+
* ```
|
|
1649
|
+
*/
|
|
1650
|
+
async restore(ctx, args) {
|
|
1651
|
+
if (!this.config.features.mediaManagement) {
|
|
1652
|
+
throw new Error("Media management feature is not enabled");
|
|
1653
|
+
}
|
|
1654
|
+
// Cast safe: restoreMediaAsset always returns kind="asset"
|
|
1655
|
+
return ctx.runMutation(this.api.mediaAssetMutations.restoreMediaAsset, args);
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Find content entries that reference a media asset.
|
|
1659
|
+
*
|
|
1660
|
+
* Useful for checking references before deletion or for understanding asset usage.
|
|
1661
|
+
*
|
|
1662
|
+
* @param ctx - Convex query context
|
|
1663
|
+
* @param args - Query arguments
|
|
1664
|
+
* @returns Array of references with entry and field information
|
|
1665
|
+
*
|
|
1666
|
+
* @example
|
|
1667
|
+
* ```typescript
|
|
1668
|
+
* // Check if asset is used before deleting
|
|
1669
|
+
* const references = await cms.mediaAssets.findReferences(ctx, {
|
|
1670
|
+
* id: assetId,
|
|
1671
|
+
* });
|
|
1672
|
+
*
|
|
1673
|
+
* if (references.length > 0) {
|
|
1674
|
+
* console.log(`Asset is used in ${references.length} entries`);
|
|
1675
|
+
* // Maybe show a warning to the user
|
|
1676
|
+
* }
|
|
1677
|
+
* ```
|
|
1678
|
+
*/
|
|
1679
|
+
async findReferences(ctx, args) {
|
|
1680
|
+
if (!this.config.features.mediaManagement) {
|
|
1681
|
+
throw new Error("Media management feature is not enabled");
|
|
1682
|
+
}
|
|
1683
|
+
// Map the wrapper's args structure to the generated API's expected structure
|
|
1684
|
+
return callQuery(ctx, this.api.mediaAssetMutations.findMediaAssetReferences, { mediaAssetId: args.id, limit: args.limit });
|
|
1685
|
+
}
|
|
1686
|
+
// ===========================================================================
|
|
1687
|
+
// Taxonomy Methods
|
|
1688
|
+
// ===========================================================================
|
|
1689
|
+
/**
|
|
1690
|
+
* Get taxonomy terms associated with a media asset.
|
|
1691
|
+
*
|
|
1692
|
+
* @param ctx - Convex query context
|
|
1693
|
+
* @param args - Query arguments
|
|
1694
|
+
* @returns Array of terms associated with the media asset
|
|
1695
|
+
*
|
|
1696
|
+
* @example
|
|
1697
|
+
* ```typescript
|
|
1698
|
+
* const tags = await cms.mediaAssets.getTerms(ctx, {
|
|
1699
|
+
* mediaId: imageId,
|
|
1700
|
+
* });
|
|
1701
|
+
* ```
|
|
1702
|
+
*/
|
|
1703
|
+
async getTerms(ctx, args) {
|
|
1704
|
+
if (!this.config.features.mediaManagement) {
|
|
1705
|
+
throw new Error("Media management feature is not enabled");
|
|
1706
|
+
}
|
|
1707
|
+
return callQuery(ctx, this.api.taxonomies.getTermsByMedia, args);
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Set terms for a media asset in a taxonomy (replaces existing terms).
|
|
1711
|
+
*
|
|
1712
|
+
* @param ctx - Convex mutation context
|
|
1713
|
+
* @param args - Mutation arguments
|
|
1714
|
+
*
|
|
1715
|
+
* @example
|
|
1716
|
+
* ```typescript
|
|
1717
|
+
* await cms.mediaAssets.setTerms(ctx, {
|
|
1718
|
+
* mediaId: imageId,
|
|
1719
|
+
* taxonomyId: categoriesTaxonomyId,
|
|
1720
|
+
* termIds: [landscapeId, natureId],
|
|
1721
|
+
* userId: currentUserId,
|
|
1722
|
+
* });
|
|
1723
|
+
* ```
|
|
1724
|
+
*/
|
|
1725
|
+
async setTerms(ctx, args) {
|
|
1726
|
+
if (!this.config.features.mediaManagement) {
|
|
1727
|
+
throw new Error("Media management feature is not enabled");
|
|
1728
|
+
}
|
|
1729
|
+
await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
|
|
1730
|
+
await ctx.runMutation(this.api.taxonomyMutations.setMediaTerms, {
|
|
1731
|
+
mediaId: args.mediaId,
|
|
1732
|
+
taxonomyId: args.taxonomyId,
|
|
1733
|
+
termIds: args.termIds,
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Add a single term to a media asset.
|
|
1738
|
+
*
|
|
1739
|
+
* @param ctx - Convex mutation context
|
|
1740
|
+
* @param args - Mutation arguments
|
|
1741
|
+
*
|
|
1742
|
+
* @example
|
|
1743
|
+
* ```typescript
|
|
1744
|
+
* await cms.mediaAssets.addTerm(ctx, {
|
|
1745
|
+
* mediaId: imageId,
|
|
1746
|
+
* termId: landscapeId,
|
|
1747
|
+
* userId: currentUserId,
|
|
1748
|
+
* });
|
|
1749
|
+
* ```
|
|
1750
|
+
*/
|
|
1751
|
+
async addTerm(ctx, args) {
|
|
1752
|
+
if (!this.config.features.mediaManagement) {
|
|
1753
|
+
throw new Error("Media management feature is not enabled");
|
|
1754
|
+
}
|
|
1755
|
+
await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
|
|
1756
|
+
await ctx.runMutation(this.api.taxonomyMutations.addTermToMedia, {
|
|
1757
|
+
mediaId: args.mediaId,
|
|
1758
|
+
termId: args.termId,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Remove a term from a media asset.
|
|
1763
|
+
*
|
|
1764
|
+
* @param ctx - Convex mutation context
|
|
1765
|
+
* @param args - Mutation arguments
|
|
1766
|
+
*
|
|
1767
|
+
* @example
|
|
1768
|
+
* ```typescript
|
|
1769
|
+
* await cms.mediaAssets.removeTerm(ctx, {
|
|
1770
|
+
* mediaId: imageId,
|
|
1771
|
+
* termId: landscapeId,
|
|
1772
|
+
* userId: currentUserId,
|
|
1773
|
+
* });
|
|
1774
|
+
* ```
|
|
1775
|
+
*/
|
|
1776
|
+
async removeTerm(ctx, args) {
|
|
1777
|
+
if (!this.config.features.mediaManagement) {
|
|
1778
|
+
throw new Error("Media management feature is not enabled");
|
|
1779
|
+
}
|
|
1780
|
+
await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
|
|
1781
|
+
await ctx.runMutation(this.api.taxonomyMutations.removeTermFromMedia, {
|
|
1782
|
+
mediaId: args.mediaId,
|
|
1783
|
+
termId: args.termId,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Create a term inline and add it to a media asset.
|
|
1788
|
+
*
|
|
1789
|
+
* @param ctx - Convex mutation context
|
|
1790
|
+
* @param args - Mutation arguments
|
|
1791
|
+
* @returns The created or existing term ID
|
|
1792
|
+
*
|
|
1793
|
+
* @example
|
|
1794
|
+
* ```typescript
|
|
1795
|
+
* const termId = await cms.mediaAssets.createAndAddTerm(ctx, {
|
|
1796
|
+
* taxonomyId: tagsTaxonomyId,
|
|
1797
|
+
* name: "Nature",
|
|
1798
|
+
* mediaId: imageId,
|
|
1799
|
+
* userId: currentUserId,
|
|
1800
|
+
* });
|
|
1801
|
+
* ```
|
|
1802
|
+
*/
|
|
1803
|
+
async createAndAddTerm(ctx, args) {
|
|
1804
|
+
if (!this.config.features.mediaManagement) {
|
|
1805
|
+
throw new Error("Media management feature is not enabled");
|
|
1806
|
+
}
|
|
1807
|
+
await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
|
|
1808
|
+
return ctx.runMutation(this.api.taxonomyMutations.createTermAndAddToMedia, args);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// =============================================================================
|
|
1812
|
+
// Media Folders API Wrapper
|
|
1813
|
+
// =============================================================================
|
|
1814
|
+
/**
|
|
1815
|
+
* Wrapper for media folder operations.
|
|
1816
|
+
*/
|
|
1817
|
+
export class MediaFoldersApi {
|
|
1818
|
+
api;
|
|
1819
|
+
config;
|
|
1820
|
+
authHelper;
|
|
1821
|
+
rateLimitHelper;
|
|
1822
|
+
constructor(api, config, authHelper, rateLimitHelper) {
|
|
1823
|
+
this.api = api;
|
|
1824
|
+
this.config = config;
|
|
1825
|
+
this.authHelper = authHelper;
|
|
1826
|
+
this.rateLimitHelper = rateLimitHelper;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Perform authorization check for media folder operations.
|
|
1830
|
+
* @param ctx - The Convex context (passed to authorization hooks for database access)
|
|
1831
|
+
* @param operation - The CMS operation being performed
|
|
1832
|
+
* @param userId - The user performing the operation
|
|
1833
|
+
* @param resourceId - Optional resource ID (for update/delete operations)
|
|
1834
|
+
* @param resourceOwnerId - Optional owner ID for ownership-based permissions
|
|
1835
|
+
*/
|
|
1836
|
+
async authorize(ctx, operation, userId, resourceId, resourceOwnerId) {
|
|
1837
|
+
if (!this.authHelper) {
|
|
1838
|
+
if (this.config.permissiveMode) {
|
|
1839
|
+
console.warn(`[ConvexCMS] Authorization not configured for "${operation}". ` +
|
|
1840
|
+
"Operations are allowed in permissiveMode, but this should NOT be used in production.");
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
throw new AuthorizationNotConfiguredError(operation);
|
|
1844
|
+
}
|
|
1845
|
+
if (this.authHelper.skipRbac) {
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (!userId) {
|
|
1849
|
+
if (this.config.permissiveMode) {
|
|
1850
|
+
console.warn(`[ConvexCMS] Anonymous operation attempted for "${operation}".`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
throw new AuthorizationNotConfiguredError(`${operation} (no userId provided - anonymous operations require permissiveMode)`);
|
|
1854
|
+
}
|
|
1855
|
+
const role = await this.authHelper.getUserRole(ctx, userId);
|
|
1856
|
+
await this.authHelper.requireAuthorization(ctx, {
|
|
1857
|
+
operation,
|
|
1858
|
+
userId,
|
|
1859
|
+
role,
|
|
1860
|
+
resourceId,
|
|
1861
|
+
resourceOwnerId,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Enforce rate limit for media folder operations.
|
|
1866
|
+
* @param ctx - The Convex context (for database access)
|
|
1867
|
+
* @param operation - The CMS operation being performed
|
|
1868
|
+
* @param userId - The user performing the operation
|
|
1869
|
+
*/
|
|
1870
|
+
async rateLimit(ctx, operation, userId) {
|
|
1871
|
+
// Skip if no rate limit helper configured
|
|
1872
|
+
if (!this.rateLimitHelper) {
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
|
|
1876
|
+
await this.rateLimitHelper.requireRateLimit(operation, {
|
|
1877
|
+
userId,
|
|
1878
|
+
role,
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Create a new media folder.
|
|
1883
|
+
*
|
|
1884
|
+
* @param ctx - Convex mutation context
|
|
1885
|
+
* @param args - Folder creation arguments
|
|
1886
|
+
* @returns The created folder
|
|
1887
|
+
*/
|
|
1888
|
+
async create(ctx, args) {
|
|
1889
|
+
if (!this.config.features.mediaManagement) {
|
|
1890
|
+
throw new Error("Media management feature is not enabled");
|
|
1891
|
+
}
|
|
1892
|
+
// Authorization check - mediaFolders.create
|
|
1893
|
+
await this.authorize(ctx, "mediaItems.create", args.createdBy);
|
|
1894
|
+
// Rate limit check - mediaFolders.create
|
|
1895
|
+
await this.rateLimit(ctx, "mediaItems.create", args.createdBy);
|
|
1896
|
+
// Cast safe: createMediaFolder always returns kind="folder"
|
|
1897
|
+
return ctx.runMutation(this.api.mediaFolderMutations.createMediaFolder, args);
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Update a media folder.
|
|
1901
|
+
*
|
|
1902
|
+
* @param ctx - Convex mutation context
|
|
1903
|
+
* @param args - Folder update arguments
|
|
1904
|
+
* @returns The updated folder
|
|
1905
|
+
*/
|
|
1906
|
+
async update(ctx, args) {
|
|
1907
|
+
if (!this.config.features.mediaManagement) {
|
|
1908
|
+
throw new Error("Media management feature is not enabled");
|
|
1909
|
+
}
|
|
1910
|
+
// Fetch folder for ownership-based authorization
|
|
1911
|
+
const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
|
|
1912
|
+
if (!folder) {
|
|
1913
|
+
throw new Error(`Media folder not found: ${args.id}`);
|
|
1914
|
+
}
|
|
1915
|
+
// Authorization check - mediaFolders.update (with ownership info)
|
|
1916
|
+
await this.authorize(ctx, "mediaItems.update", args.updatedBy, args.id, folder.createdBy);
|
|
1917
|
+
// Rate limit check - mediaFolders.update
|
|
1918
|
+
await this.rateLimit(ctx, "mediaItems.update", args.updatedBy);
|
|
1919
|
+
// Cast safe: updateMediaFolder always returns kind="folder"
|
|
1920
|
+
return ctx.runMutation(this.api.mediaFolderMutations.updateMediaFolder, args);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Soft delete a media folder.
|
|
1924
|
+
*
|
|
1925
|
+
* @param ctx - Convex mutation context
|
|
1926
|
+
* @param args - Delete arguments
|
|
1927
|
+
* @returns The deleted folder
|
|
1928
|
+
*/
|
|
1929
|
+
async delete(ctx, args) {
|
|
1930
|
+
if (!this.config.features.mediaManagement) {
|
|
1931
|
+
throw new Error("Media management feature is not enabled");
|
|
1932
|
+
}
|
|
1933
|
+
// Fetch folder for ownership-based authorization
|
|
1934
|
+
const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
|
|
1935
|
+
if (!folder) {
|
|
1936
|
+
throw new Error(`Media folder not found: ${args.id}`);
|
|
1937
|
+
}
|
|
1938
|
+
// Authorization check - mediaFolders.delete (with ownership info)
|
|
1939
|
+
await this.authorize(ctx, "mediaItems.delete", args.deletedBy, args.id, folder.createdBy);
|
|
1940
|
+
// Rate limit check - mediaFolders.delete
|
|
1941
|
+
await this.rateLimit(ctx, "mediaItems.delete", args.deletedBy);
|
|
1942
|
+
// Cast safe: deleteMediaFolder always returns kind="folder"
|
|
1943
|
+
return ctx.runMutation(this.api.mediaFolderMutations.deleteMediaFolder, args);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Get a media folder by ID.
|
|
1947
|
+
*
|
|
1948
|
+
* @param ctx - Convex query context
|
|
1949
|
+
* @param args - Get arguments
|
|
1950
|
+
* @returns The folder or null if not found
|
|
1951
|
+
*/
|
|
1952
|
+
async get(ctx, args) {
|
|
1953
|
+
if (!this.config.features.mediaManagement) {
|
|
1954
|
+
throw new Error("Media management feature is not enabled");
|
|
1955
|
+
}
|
|
1956
|
+
// Cast safe: getMediaFolder filters for kind="folder"
|
|
1957
|
+
return ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, args);
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* List media folders.
|
|
1961
|
+
*
|
|
1962
|
+
* @param ctx - Convex query context
|
|
1963
|
+
* @param args - Optional filter arguments
|
|
1964
|
+
* @returns Array of folders
|
|
1965
|
+
*/
|
|
1966
|
+
async list(ctx, args = {}) {
|
|
1967
|
+
if (!this.config.features.mediaManagement) {
|
|
1968
|
+
throw new Error("Media management feature is not enabled");
|
|
1969
|
+
}
|
|
1970
|
+
// Cast safe: listMediaFolders filters for kind="folder"
|
|
1971
|
+
return ctx.runQuery(this.api.mediaFolderMutations.listMediaFolders, args);
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Move a folder to a new parent.
|
|
1975
|
+
*
|
|
1976
|
+
* @param ctx - Convex mutation context
|
|
1977
|
+
* @param args - Move arguments
|
|
1978
|
+
* @returns The moved folder with updated path
|
|
1979
|
+
*/
|
|
1980
|
+
async move(ctx, args) {
|
|
1981
|
+
if (!this.config.features.mediaManagement) {
|
|
1982
|
+
throw new Error("Media management feature is not enabled");
|
|
1983
|
+
}
|
|
1984
|
+
// Fetch folder for ownership-based authorization
|
|
1985
|
+
const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
|
|
1986
|
+
if (!folder) {
|
|
1987
|
+
throw new Error(`Media folder not found: ${args.id}`);
|
|
1988
|
+
}
|
|
1989
|
+
// Authorization check - mediaFolders.move (with ownership info)
|
|
1990
|
+
await this.authorize(ctx, "mediaItems.move", args.updatedBy, args.id, folder.createdBy);
|
|
1991
|
+
// Rate limit check - mediaFolders.move
|
|
1992
|
+
await this.rateLimit(ctx, "mediaItems.move", args.updatedBy);
|
|
1993
|
+
// Cast safe: moveMediaFolder always returns kind="folder"
|
|
1994
|
+
return ctx.runMutation(this.api.mediaFolderMutations.moveMediaFolder, args);
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* Restore a soft-deleted media folder.
|
|
1998
|
+
*
|
|
1999
|
+
* @param ctx - Convex mutation context
|
|
2000
|
+
* @param args - Restore arguments
|
|
2001
|
+
* @returns The restored folder
|
|
2002
|
+
*
|
|
2003
|
+
* @example
|
|
2004
|
+
* ```typescript
|
|
2005
|
+
* // Restore a folder and all its contents
|
|
2006
|
+
* const restoredFolder = await cms.mediaFolders.restore(ctx, {
|
|
2007
|
+
* id: folderId,
|
|
2008
|
+
* recursive: true,
|
|
2009
|
+
* });
|
|
2010
|
+
* ```
|
|
2011
|
+
*/
|
|
2012
|
+
async restore(ctx, args) {
|
|
2013
|
+
if (!this.config.features.mediaManagement) {
|
|
2014
|
+
throw new Error("Media management feature is not enabled");
|
|
2015
|
+
}
|
|
2016
|
+
// Cast safe: restoreMediaFolder always returns kind="folder"
|
|
2017
|
+
return ctx.runMutation(this.api.mediaFolderMutations.restoreMediaFolder, args);
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Get a folder by its path.
|
|
2021
|
+
*
|
|
2022
|
+
* @param ctx - Convex query context
|
|
2023
|
+
* @param args - Query arguments with path
|
|
2024
|
+
* @returns The folder or null if not found
|
|
2025
|
+
*
|
|
2026
|
+
* @example
|
|
2027
|
+
* ```typescript
|
|
2028
|
+
* // Find folder by path
|
|
2029
|
+
* const folder = await cms.mediaFolders.getByPath(ctx, {
|
|
2030
|
+
* path: "/Images/Blog/2026",
|
|
2031
|
+
* });
|
|
2032
|
+
* ```
|
|
2033
|
+
*/
|
|
2034
|
+
async getByPath(ctx, args) {
|
|
2035
|
+
if (!this.config.features.mediaManagement) {
|
|
2036
|
+
throw new Error("Media management feature is not enabled");
|
|
2037
|
+
}
|
|
2038
|
+
// Cast safe: getMediaFolderByPath filters for kind="folder"
|
|
2039
|
+
return ctx.runQuery(this.api.mediaFolderMutations.getMediaFolderByPath, args);
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Get the entire folder tree as a flat list sorted by path.
|
|
2043
|
+
*
|
|
2044
|
+
* Useful for building folder navigation or selectors.
|
|
2045
|
+
*
|
|
2046
|
+
* @param ctx - Convex query context
|
|
2047
|
+
* @param args - Optional filter arguments
|
|
2048
|
+
* @returns Array of all folders sorted hierarchically by path
|
|
2049
|
+
*
|
|
2050
|
+
* @example
|
|
2051
|
+
* ```typescript
|
|
2052
|
+
* // Get all folders for a tree view
|
|
2053
|
+
* const folders = await cms.mediaFolders.getTree(ctx, {});
|
|
2054
|
+
*
|
|
2055
|
+
* // Build a nested structure
|
|
2056
|
+
* const rootFolders = folders.filter(f => !f.parentId);
|
|
2057
|
+
* ```
|
|
2058
|
+
*/
|
|
2059
|
+
async getTree(ctx, args = {}) {
|
|
2060
|
+
if (!this.config.features.mediaManagement) {
|
|
2061
|
+
throw new Error("Media management feature is not enabled");
|
|
2062
|
+
}
|
|
2063
|
+
// Cast safe: getFolderTree filters for kind="folder"
|
|
2064
|
+
return ctx.runQuery(this.api.mediaFolderMutations.getFolderTree, args);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
// =============================================================================
|
|
2068
|
+
// Media Variants API Wrapper
|
|
2069
|
+
// =============================================================================
|
|
2070
|
+
/**
|
|
2071
|
+
* Wrapper for media variant operations.
|
|
2072
|
+
*
|
|
2073
|
+
* Media variants are optimized versions of media assets (thumbnails, responsive
|
|
2074
|
+
* sizes, format conversions). This API provides methods for creating, listing,
|
|
2075
|
+
* and managing variants.
|
|
2076
|
+
*
|
|
2077
|
+
* @example
|
|
2078
|
+
* ```typescript
|
|
2079
|
+
* // Get all variants for an asset
|
|
2080
|
+
* const variants = await cms.mediaVariants.list(ctx, {
|
|
2081
|
+
* assetId: assetId,
|
|
2082
|
+
* status: "completed",
|
|
2083
|
+
* });
|
|
2084
|
+
*
|
|
2085
|
+
* // Get responsive srcset for an image
|
|
2086
|
+
* const srcset = await cms.mediaVariants.getResponsiveSrcset(ctx, {
|
|
2087
|
+
* assetId: assetId,
|
|
2088
|
+
* format: "webp",
|
|
2089
|
+
* });
|
|
2090
|
+
* // Use: <img src={srcset.src} srcset={srcset.srcset} sizes="100vw" />
|
|
2091
|
+
* ```
|
|
2092
|
+
*/
|
|
2093
|
+
export class MediaVariantsApi {
|
|
2094
|
+
api;
|
|
2095
|
+
config;
|
|
2096
|
+
constructor(api, config) {
|
|
2097
|
+
this.api = api;
|
|
2098
|
+
this.config = config;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Create a media variant after external processing.
|
|
2102
|
+
*
|
|
2103
|
+
* Use this when variant processing happens externally (e.g., in a serverless
|
|
2104
|
+
* function or image processing service) and you need to register the
|
|
2105
|
+
* completed variant.
|
|
2106
|
+
*
|
|
2107
|
+
* @param ctx - Convex mutation context
|
|
2108
|
+
* @param args - Variant creation arguments
|
|
2109
|
+
* @returns The created variant with URL
|
|
2110
|
+
*
|
|
2111
|
+
* @example
|
|
2112
|
+
* ```typescript
|
|
2113
|
+
* // After processing image externally and uploading result
|
|
2114
|
+
* const variant = await cms.mediaVariants.create(ctx, {
|
|
2115
|
+
* assetId: assetId,
|
|
2116
|
+
* storageId: processedStorageId,
|
|
2117
|
+
* variantType: "responsive",
|
|
2118
|
+
* width: 480,
|
|
2119
|
+
* height: 320,
|
|
2120
|
+
* format: "webp",
|
|
2121
|
+
* mimeType: "image/webp",
|
|
2122
|
+
* size: 25600,
|
|
2123
|
+
* quality: 80,
|
|
2124
|
+
* preset: "small",
|
|
2125
|
+
* });
|
|
2126
|
+
* ```
|
|
2127
|
+
*/
|
|
2128
|
+
async create(ctx, args) {
|
|
2129
|
+
if (!this.config.features.mediaManagement) {
|
|
2130
|
+
throw new Error("Media management feature is not enabled");
|
|
2131
|
+
}
|
|
2132
|
+
return ctx.runMutation(this.api.mediaVariantMutations.createMediaVariant, args);
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Request async generation of a variant.
|
|
2136
|
+
*
|
|
2137
|
+
* Creates a variant record with "pending" status. An external processing
|
|
2138
|
+
* system should pick up pending variants, process them, and update the status.
|
|
2139
|
+
*
|
|
2140
|
+
* @param ctx - Convex mutation context
|
|
2141
|
+
* @param args - Generation request arguments
|
|
2142
|
+
* @returns The pending variant
|
|
2143
|
+
*/
|
|
2144
|
+
async requestGeneration(ctx, args) {
|
|
2145
|
+
if (!this.config.features.mediaManagement) {
|
|
2146
|
+
throw new Error("Media management feature is not enabled");
|
|
2147
|
+
}
|
|
2148
|
+
return ctx.runMutation(this.api.mediaVariantMutations.requestVariantGeneration, args);
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Get a variant by ID.
|
|
2152
|
+
*
|
|
2153
|
+
* @param ctx - Convex query context
|
|
2154
|
+
* @param args - Query arguments
|
|
2155
|
+
* @returns The variant with URL or null
|
|
2156
|
+
*/
|
|
2157
|
+
async get(ctx, args) {
|
|
2158
|
+
if (!this.config.features.mediaManagement) {
|
|
2159
|
+
throw new Error("Media management feature is not enabled");
|
|
2160
|
+
}
|
|
2161
|
+
return ctx.runQuery(this.api.mediaVariants.get, args);
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* List variants for an asset.
|
|
2165
|
+
*
|
|
2166
|
+
* @param ctx - Convex query context
|
|
2167
|
+
* @param args - Query arguments with filters
|
|
2168
|
+
* @returns Array of variants with URLs
|
|
2169
|
+
*
|
|
2170
|
+
* @example
|
|
2171
|
+
* ```typescript
|
|
2172
|
+
* // Get all completed responsive variants
|
|
2173
|
+
* const variants = await cms.mediaVariants.list(ctx, {
|
|
2174
|
+
* assetId: assetId,
|
|
2175
|
+
* variantType: "responsive",
|
|
2176
|
+
* status: "completed",
|
|
2177
|
+
* });
|
|
2178
|
+
* ```
|
|
2179
|
+
*/
|
|
2180
|
+
async list(ctx, args) {
|
|
2181
|
+
if (!this.config.features.mediaManagement) {
|
|
2182
|
+
throw new Error("Media management feature is not enabled");
|
|
2183
|
+
}
|
|
2184
|
+
return ctx.runQuery(this.api.mediaVariants.list, args);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Find the best matching variant for target dimensions.
|
|
2188
|
+
*
|
|
2189
|
+
* @param ctx - Convex query context
|
|
2190
|
+
* @param args - Target size and preferences
|
|
2191
|
+
* @returns Best matching variant or null
|
|
2192
|
+
*
|
|
2193
|
+
* @example
|
|
2194
|
+
* ```typescript
|
|
2195
|
+
* // Get best variant for 400px wide container
|
|
2196
|
+
* const variant = await cms.mediaVariants.getBestVariant(ctx, {
|
|
2197
|
+
* assetId: assetId,
|
|
2198
|
+
* targetWidth: 400,
|
|
2199
|
+
* preferredFormat: "webp",
|
|
2200
|
+
* });
|
|
2201
|
+
* ```
|
|
2202
|
+
*/
|
|
2203
|
+
async getBestVariant(ctx, args) {
|
|
2204
|
+
if (!this.config.features.mediaManagement) {
|
|
2205
|
+
throw new Error("Media management feature is not enabled");
|
|
2206
|
+
}
|
|
2207
|
+
return ctx.runQuery(this.api.mediaVariants.getBestVariant, args);
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Get responsive srcset data for HTML img/picture tags.
|
|
2211
|
+
*
|
|
2212
|
+
* @param ctx - Convex query context
|
|
2213
|
+
* @param args - Asset ID and optional format filter
|
|
2214
|
+
* @returns Srcset data for responsive images
|
|
2215
|
+
*
|
|
2216
|
+
* @example
|
|
2217
|
+
* ```typescript
|
|
2218
|
+
* const srcset = await cms.mediaVariants.getResponsiveSrcset(ctx, {
|
|
2219
|
+
* assetId: assetId,
|
|
2220
|
+
* format: "webp",
|
|
2221
|
+
* });
|
|
2222
|
+
*
|
|
2223
|
+
* // In React:
|
|
2224
|
+
* <img src={srcset.src} srcset={srcset.srcset} sizes="100vw" />
|
|
2225
|
+
* ```
|
|
2226
|
+
*/
|
|
2227
|
+
async getResponsiveSrcset(ctx, args) {
|
|
2228
|
+
if (!this.config.features.mediaManagement) {
|
|
2229
|
+
throw new Error("Media management feature is not enabled");
|
|
2230
|
+
}
|
|
2231
|
+
return ctx.runQuery(this.api.mediaVariants.getResponsiveSrcset, args);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Get an asset with all its variants organized by type.
|
|
2235
|
+
*
|
|
2236
|
+
* @param ctx - Convex query context
|
|
2237
|
+
* @param args - Asset ID
|
|
2238
|
+
* @returns Asset with variants or null
|
|
2239
|
+
*/
|
|
2240
|
+
async getAssetWithVariants(ctx, args) {
|
|
2241
|
+
if (!this.config.features.mediaManagement) {
|
|
2242
|
+
throw new Error("Media management feature is not enabled");
|
|
2243
|
+
}
|
|
2244
|
+
return ctx.runQuery(this.api.mediaVariants.getAssetWithVariants, args);
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Get available variant presets.
|
|
2248
|
+
*
|
|
2249
|
+
* @param ctx - Convex query context
|
|
2250
|
+
* @returns Array of preset configurations
|
|
2251
|
+
*/
|
|
2252
|
+
async getPresets(ctx) {
|
|
2253
|
+
if (!this.config.features.mediaManagement) {
|
|
2254
|
+
throw new Error("Media management feature is not enabled");
|
|
2255
|
+
}
|
|
2256
|
+
return ctx.runQuery(this.api.mediaVariants.getPresets, {});
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Generate variants from preset configurations.
|
|
2260
|
+
*
|
|
2261
|
+
* Queues multiple variants for async processing.
|
|
2262
|
+
*
|
|
2263
|
+
* @param ctx - Convex mutation context
|
|
2264
|
+
* @param args - Asset ID and preset names
|
|
2265
|
+
* @returns Summary of created variant requests
|
|
2266
|
+
*
|
|
2267
|
+
* @example
|
|
2268
|
+
* ```typescript
|
|
2269
|
+
* // Generate standard responsive set
|
|
2270
|
+
* const result = await cms.mediaVariants.generateFromPresets(ctx, {
|
|
2271
|
+
* assetId: assetId,
|
|
2272
|
+
* presets: ["thumbnail", "small", "medium", "large"],
|
|
2273
|
+
* });
|
|
2274
|
+
* console.log(`Queued ${result.succeeded} variants`);
|
|
2275
|
+
* ```
|
|
2276
|
+
*/
|
|
2277
|
+
async generateFromPresets(ctx, args) {
|
|
2278
|
+
if (!this.config.features.mediaManagement) {
|
|
2279
|
+
throw new Error("Media management feature is not enabled");
|
|
2280
|
+
}
|
|
2281
|
+
return ctx.runMutation(this.api.mediaVariantMutations.generateFromPresets, args);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Delete a variant.
|
|
2285
|
+
*
|
|
2286
|
+
* @param ctx - Convex mutation context
|
|
2287
|
+
* @param args - Delete arguments
|
|
2288
|
+
* @returns The deleted variant
|
|
2289
|
+
*/
|
|
2290
|
+
async delete(ctx, args) {
|
|
2291
|
+
if (!this.config.features.mediaManagement) {
|
|
2292
|
+
throw new Error("Media management feature is not enabled");
|
|
2293
|
+
}
|
|
2294
|
+
return ctx.runMutation(this.api.mediaVariantMutations.deleteMediaVariant, args);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Delete all variants for an asset.
|
|
2298
|
+
*
|
|
2299
|
+
* @param ctx - Convex mutation context
|
|
2300
|
+
* @param args - Asset ID and delete options
|
|
2301
|
+
* @returns Summary of deleted variants
|
|
2302
|
+
*/
|
|
2303
|
+
async deleteAllForAsset(ctx, args) {
|
|
2304
|
+
if (!this.config.features.mediaManagement) {
|
|
2305
|
+
throw new Error("Media management feature is not enabled");
|
|
2306
|
+
}
|
|
2307
|
+
return ctx.runMutation(this.api.mediaVariantMutations.deleteAssetVariants, args);
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Restore a soft-deleted variant.
|
|
2311
|
+
*
|
|
2312
|
+
* @param ctx - Convex mutation context
|
|
2313
|
+
* @param args - Variant ID to restore
|
|
2314
|
+
* @returns The restored variant
|
|
2315
|
+
*/
|
|
2316
|
+
async restore(ctx, args) {
|
|
2317
|
+
if (!this.config.features.mediaManagement) {
|
|
2318
|
+
throw new Error("Media management feature is not enabled");
|
|
2319
|
+
}
|
|
2320
|
+
return ctx.runMutation(this.api.mediaVariantMutations.restoreMediaVariant, args);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Creates an enhanced CMS client with typed method wrappers.
|
|
2325
|
+
*
|
|
2326
|
+
* This is the main entry point for using the Convex CMS component.
|
|
2327
|
+
* The returned client provides typed methods for all CMS operations.
|
|
2328
|
+
*
|
|
2329
|
+
* @param componentApi - The component API from `components.convexCms`
|
|
2330
|
+
* @param config - Optional configuration options
|
|
2331
|
+
* @returns An enhanced CMS client instance
|
|
2332
|
+
*
|
|
2333
|
+
* @example
|
|
2334
|
+
* ```typescript
|
|
2335
|
+
* import { createCmsClient } from "@convex-cms/core";
|
|
2336
|
+
* import { components } from "./_generated/api";
|
|
2337
|
+
*
|
|
2338
|
+
* // Create with default configuration
|
|
2339
|
+
* export const cms = createCmsClient(components.convexCms);
|
|
2340
|
+
*
|
|
2341
|
+
* // Create with custom configuration
|
|
2342
|
+
* export const cms = createCmsClient(components.convexCms, {
|
|
2343
|
+
* defaultLocale: "en-US",
|
|
2344
|
+
* supportedLocales: ["en-US", "es-ES", "fr-FR"],
|
|
2345
|
+
* features: {
|
|
2346
|
+
* versioning: true,
|
|
2347
|
+
* localization: true,
|
|
2348
|
+
* scheduling: true,
|
|
2349
|
+
* },
|
|
2350
|
+
* maxVersionsPerEntry: 100,
|
|
2351
|
+
* });
|
|
2352
|
+
* ```
|
|
2353
|
+
*/
|
|
2354
|
+
export function createCmsClient(componentApi, config) {
|
|
2355
|
+
const resolvedConfig = resolveConfig(config);
|
|
2356
|
+
// Store the getUserRole hook from the original config (not resolved)
|
|
2357
|
+
const getUserRoleHook = config?.getUserRole;
|
|
2358
|
+
// Store authorization hooks from config
|
|
2359
|
+
const authHooks = config?.authorizationHooks;
|
|
2360
|
+
// Store rate limit hooks from config
|
|
2361
|
+
const rateLimitHooks = config?.rateLimitHooks;
|
|
2362
|
+
// Create rate limit helper for API classes (only if rateLimitHooks are configured)
|
|
2363
|
+
const rateLimitHelper = rateLimitHooks
|
|
2364
|
+
? {
|
|
2365
|
+
async getUserRole(ctx, userId) {
|
|
2366
|
+
if (!getUserRoleHook)
|
|
2367
|
+
return null;
|
|
2368
|
+
return getUserRoleHook(ctx, { userId });
|
|
2369
|
+
},
|
|
2370
|
+
async requireRateLimit(operation, options) {
|
|
2371
|
+
const context = createRateLimitContext(operation, options);
|
|
2372
|
+
return requireRateLimit({
|
|
2373
|
+
hooks: rateLimitHooks,
|
|
2374
|
+
context,
|
|
2375
|
+
});
|
|
2376
|
+
},
|
|
2377
|
+
}
|
|
2378
|
+
: undefined;
|
|
2379
|
+
// Create authorization helper for API classes (only if getUserRole is configured)
|
|
2380
|
+
const authHelper = getUserRoleHook
|
|
2381
|
+
? {
|
|
2382
|
+
async getUserRole(ctx, userId) {
|
|
2383
|
+
return getUserRoleHook(ctx, { userId });
|
|
2384
|
+
},
|
|
2385
|
+
async requireAuthorization(ctx, context) {
|
|
2386
|
+
const fullContext = {
|
|
2387
|
+
...context,
|
|
2388
|
+
ctx: ctx,
|
|
2389
|
+
};
|
|
2390
|
+
const rbacOptions = contextToRbacOptions(fullContext);
|
|
2391
|
+
const result = await executeAuthorizationHooks({
|
|
2392
|
+
hooks: authHooks,
|
|
2393
|
+
context: fullContext,
|
|
2394
|
+
rbacOptions: rbacOptions ?? undefined,
|
|
2395
|
+
skipRbac: resolvedConfig.skipRbac,
|
|
2396
|
+
});
|
|
2397
|
+
if (!result.allowed) {
|
|
2398
|
+
const rbacMapping = operationToRbac(fullContext.operation);
|
|
2399
|
+
// Import UnauthorizedError dynamically to avoid circular dependency
|
|
2400
|
+
const { UnauthorizedError } = await import("../component/authorization.js");
|
|
2401
|
+
throw new UnauthorizedError(result.reason ?? "Operation not allowed", {
|
|
2402
|
+
code: result.rbacResult?.allowed === false
|
|
2403
|
+
? result.rbacResult.code
|
|
2404
|
+
: "PERMISSION_DENIED",
|
|
2405
|
+
resource: rbacMapping?.resource,
|
|
2406
|
+
action: rbacMapping?.action,
|
|
2407
|
+
role: fullContext.role ?? undefined,
|
|
2408
|
+
userId: fullContext.userId,
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
return result;
|
|
2412
|
+
},
|
|
2413
|
+
skipRbac: resolvedConfig.skipRbac ?? false,
|
|
2414
|
+
}
|
|
2415
|
+
: undefined;
|
|
2416
|
+
return {
|
|
2417
|
+
config: resolvedConfig,
|
|
2418
|
+
api: componentApi,
|
|
2419
|
+
contentTypes: new ContentTypesApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
|
|
2420
|
+
contentEntries: new ContentEntriesApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
|
|
2421
|
+
versions: new VersionsApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
|
|
2422
|
+
mediaAssets: new MediaAssetsApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
|
|
2423
|
+
mediaFolders: new MediaFoldersApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
|
|
2424
|
+
mediaVariants: new MediaVariantsApi(componentApi, resolvedConfig),
|
|
2425
|
+
// Locale fallback chain helpers
|
|
2426
|
+
locale: {
|
|
2427
|
+
getConfig() {
|
|
2428
|
+
return {
|
|
2429
|
+
defaultLocale: resolvedConfig.defaultLocale,
|
|
2430
|
+
fallbackChains: resolvedConfig.localeFallbackChains,
|
|
2431
|
+
autoGenerateFallbacks: resolvedConfig.autoGenerateLocaleFallbacks,
|
|
2432
|
+
supportedLocales: resolvedConfig.supportedLocales,
|
|
2433
|
+
};
|
|
2434
|
+
},
|
|
2435
|
+
getFallbackChain(locale) {
|
|
2436
|
+
const fallbackConfig = this.getConfig();
|
|
2437
|
+
return getFallbackChain(locale, fallbackConfig);
|
|
2438
|
+
},
|
|
2439
|
+
resolve(locale) {
|
|
2440
|
+
const fallbackConfig = this.getConfig();
|
|
2441
|
+
return resolveFallbackChain(locale, fallbackConfig);
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
isFeatureEnabled(feature) {
|
|
2445
|
+
return resolvedConfig.features[feature] ?? false;
|
|
2446
|
+
},
|
|
2447
|
+
isLocaleSupported(locale) {
|
|
2448
|
+
return resolvedConfig.supportedLocales.includes(locale);
|
|
2449
|
+
},
|
|
2450
|
+
hasUserRoleHook() {
|
|
2451
|
+
return getUserRoleHook !== undefined;
|
|
2452
|
+
},
|
|
2453
|
+
hasAuthorizationHooks() {
|
|
2454
|
+
if (!authHooks)
|
|
2455
|
+
return false;
|
|
2456
|
+
return !!(authHooks.beforeRbac ||
|
|
2457
|
+
authHooks.afterRbac ||
|
|
2458
|
+
authHooks.onDeny ||
|
|
2459
|
+
(authHooks.operationHooks && Object.keys(authHooks.operationHooks).length > 0));
|
|
2460
|
+
},
|
|
2461
|
+
async getUserRole(ctx, userId) {
|
|
2462
|
+
if (!getUserRoleHook) {
|
|
2463
|
+
throw new Error("No getUserRole hook configured. " +
|
|
2464
|
+
"Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles.");
|
|
2465
|
+
}
|
|
2466
|
+
return await getUserRoleHook(ctx, { userId });
|
|
2467
|
+
},
|
|
2468
|
+
async hasPermissionForUser(ctx, userId, permission, options) {
|
|
2469
|
+
if (!getUserRoleHook) {
|
|
2470
|
+
throw new Error("No getUserRole hook configured. " +
|
|
2471
|
+
"Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles.");
|
|
2472
|
+
}
|
|
2473
|
+
const role = await getUserRoleHook(ctx, { userId });
|
|
2474
|
+
// If user has no role, they have no permissions
|
|
2475
|
+
if (role === null) {
|
|
2476
|
+
return {
|
|
2477
|
+
allowed: false,
|
|
2478
|
+
role: null,
|
|
2479
|
+
permission,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
// Check if the role has the requested permission
|
|
2483
|
+
const allowed = hasPermission(role, permission, options?.customRoles);
|
|
2484
|
+
return {
|
|
2485
|
+
allowed,
|
|
2486
|
+
role,
|
|
2487
|
+
permission,
|
|
2488
|
+
};
|
|
2489
|
+
},
|
|
2490
|
+
async authorize(context) {
|
|
2491
|
+
// Build RBAC options from context
|
|
2492
|
+
const rbacOptions = contextToRbacOptions(context);
|
|
2493
|
+
return executeAuthorizationHooks({
|
|
2494
|
+
hooks: authHooks,
|
|
2495
|
+
context,
|
|
2496
|
+
rbacOptions: rbacOptions ?? undefined,
|
|
2497
|
+
skipRbac: resolvedConfig.skipRbac,
|
|
2498
|
+
});
|
|
2499
|
+
},
|
|
2500
|
+
async requireAuthorization(context) {
|
|
2501
|
+
const result = await this.authorize(context);
|
|
2502
|
+
if (!result.allowed) {
|
|
2503
|
+
const rbacMapping = operationToRbac(context.operation);
|
|
2504
|
+
// Import UnauthorizedError dynamically to avoid circular dependency
|
|
2505
|
+
const { UnauthorizedError } = await import("../component/authorization.js");
|
|
2506
|
+
throw new UnauthorizedError(result.reason ?? "Operation not allowed", {
|
|
2507
|
+
code: result.rbacResult?.allowed === false
|
|
2508
|
+
? result.rbacResult.code
|
|
2509
|
+
: "PERMISSION_DENIED",
|
|
2510
|
+
resource: rbacMapping?.resource,
|
|
2511
|
+
action: rbacMapping?.action,
|
|
2512
|
+
role: context.role ?? undefined,
|
|
2513
|
+
userId: context.userId,
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
return result;
|
|
2517
|
+
},
|
|
2518
|
+
// ==========================================================================
|
|
2519
|
+
// Custom Roles Methods
|
|
2520
|
+
// ==========================================================================
|
|
2521
|
+
getCustomRoles() {
|
|
2522
|
+
return resolvedConfig.customRoles;
|
|
2523
|
+
},
|
|
2524
|
+
getCustomRole(roleName) {
|
|
2525
|
+
return resolvedConfig.customRoles[roleName];
|
|
2526
|
+
},
|
|
2527
|
+
isCustomRole(roleName) {
|
|
2528
|
+
return roleName in resolvedConfig.customRoles;
|
|
2529
|
+
},
|
|
2530
|
+
async hasContentTypePermissionForUser(ctx, userId, permission, contentTypeName) {
|
|
2531
|
+
if (!getUserRoleHook) {
|
|
2532
|
+
throw new Error("No getUserRole hook configured. " +
|
|
2533
|
+
"Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles.");
|
|
2534
|
+
}
|
|
2535
|
+
const role = await getUserRoleHook(ctx, { userId });
|
|
2536
|
+
if (role === null) {
|
|
2537
|
+
return {
|
|
2538
|
+
allowed: false,
|
|
2539
|
+
role: null,
|
|
2540
|
+
permission,
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
// Use the content-type-aware permission check
|
|
2544
|
+
const allowed = hasContentTypePermission(role, permission, {
|
|
2545
|
+
customRoles: resolvedConfig.customRoles,
|
|
2546
|
+
contentTypeName,
|
|
2547
|
+
});
|
|
2548
|
+
return {
|
|
2549
|
+
allowed,
|
|
2550
|
+
role,
|
|
2551
|
+
permission,
|
|
2552
|
+
};
|
|
2553
|
+
},
|
|
2554
|
+
async getPermittedContentTypesForUser(ctx, userId, action) {
|
|
2555
|
+
if (!getUserRoleHook) {
|
|
2556
|
+
throw new Error("No getUserRole hook configured. " +
|
|
2557
|
+
"Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles.");
|
|
2558
|
+
}
|
|
2559
|
+
const role = await getUserRoleHook(ctx, { userId });
|
|
2560
|
+
if (role === null) {
|
|
2561
|
+
return [];
|
|
2562
|
+
}
|
|
2563
|
+
return getPermittedContentTypes(role, action, {
|
|
2564
|
+
customRoles: resolvedConfig.customRoles,
|
|
2565
|
+
});
|
|
2566
|
+
},
|
|
2567
|
+
getAllRoles() {
|
|
2568
|
+
return {
|
|
2569
|
+
...DEFAULT_ROLES,
|
|
2570
|
+
...resolvedConfig.customRoles,
|
|
2571
|
+
};
|
|
2572
|
+
},
|
|
2573
|
+
// ==========================================================================
|
|
2574
|
+
// Resource Ownership Methods
|
|
2575
|
+
// ==========================================================================
|
|
2576
|
+
async canUserPerformOnResource(ctx, userId, resource, action, resourceOwnerId) {
|
|
2577
|
+
if (!getUserRoleHook) {
|
|
2578
|
+
throw new Error("No getUserRole hook configured. " +
|
|
2579
|
+
"Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles.");
|
|
2580
|
+
}
|
|
2581
|
+
const role = await getUserRoleHook(ctx, { userId });
|
|
2582
|
+
// If user has no role, they have no permissions
|
|
2583
|
+
if (role === null) {
|
|
2584
|
+
return {
|
|
2585
|
+
allowed: false,
|
|
2586
|
+
role: null,
|
|
2587
|
+
reason: "No role assigned to user",
|
|
2588
|
+
code: "NO_ROLE",
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
// Use the core checkPermission function for comprehensive RBAC check
|
|
2592
|
+
const { checkPermission } = await import("../component/authorization.js");
|
|
2593
|
+
const result = checkPermission({
|
|
2594
|
+
userId,
|
|
2595
|
+
role,
|
|
2596
|
+
resource,
|
|
2597
|
+
action,
|
|
2598
|
+
resourceOwnerId,
|
|
2599
|
+
customRoles: resolvedConfig.customRoles,
|
|
2600
|
+
});
|
|
2601
|
+
if (result.allowed === true) {
|
|
2602
|
+
return {
|
|
2603
|
+
allowed: true,
|
|
2604
|
+
role,
|
|
2605
|
+
grantedScope: result.grantedScope,
|
|
2606
|
+
ownershipVerified: result.ownershipVerified,
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
else {
|
|
2610
|
+
// TypeScript narrows result to PermissionDenied when allowed === false
|
|
2611
|
+
const denied = result;
|
|
2612
|
+
return {
|
|
2613
|
+
allowed: false,
|
|
2614
|
+
role,
|
|
2615
|
+
reason: denied.reason,
|
|
2616
|
+
code: denied.code,
|
|
2617
|
+
ownershipRequired: denied.code === "OWNERSHIP_REQUIRED",
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
},
|
|
2621
|
+
async requireUserCanPerformOnResource(ctx, userId, resource, action, resourceOwnerId) {
|
|
2622
|
+
const result = await this.canUserPerformOnResource(ctx, userId, resource, action, resourceOwnerId);
|
|
2623
|
+
if (!result.allowed) {
|
|
2624
|
+
// Import UnauthorizedError dynamically to avoid circular dependency
|
|
2625
|
+
const { UnauthorizedError } = await import("../component/authorization.js");
|
|
2626
|
+
throw new UnauthorizedError(result.reason ?? "Operation not allowed", {
|
|
2627
|
+
code: (result.code ?? "PERMISSION_DENIED"),
|
|
2628
|
+
resource,
|
|
2629
|
+
action,
|
|
2630
|
+
role: result.role ?? undefined,
|
|
2631
|
+
userId,
|
|
2632
|
+
requiredScope: result.ownershipRequired ? "own" : undefined,
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
return {
|
|
2636
|
+
allowed: true,
|
|
2637
|
+
role: result.role,
|
|
2638
|
+
grantedScope: result.grantedScope,
|
|
2639
|
+
ownershipVerified: result.ownershipVerified ?? false,
|
|
2640
|
+
};
|
|
2641
|
+
},
|
|
2642
|
+
isOwner(userId, resourceOwnerId) {
|
|
2643
|
+
// Import the helper synchronously (it's a simple comparison)
|
|
2644
|
+
if (userId === undefined || resourceOwnerId === undefined) {
|
|
2645
|
+
return false;
|
|
2646
|
+
}
|
|
2647
|
+
return userId === resourceOwnerId;
|
|
2648
|
+
},
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
//# sourceMappingURL=wrapper.js.map
|