convex-cms 0.0.2 → 0.0.3
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/admin-dist/nitro.json +15 -0
- package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
- package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
- package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
- package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
- package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
- package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
- package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
- package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
- package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
- package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
- package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
- package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
- package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
- package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
- package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
- package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
- package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
- package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
- package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
- package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
- package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
- package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
- package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
- package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
- package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
- package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
- package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
- package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
- package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
- package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
- package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
- package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
- package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
- package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
- package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
- package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
- package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
- package/admin-dist/public/favicon.ico +0 -0
- package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
- package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
- package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
- package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
- package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
- package/admin-dist/server/_libs/clsx.mjs +16 -0
- package/admin-dist/server/_libs/cmdk.mjs +315 -0
- package/admin-dist/server/_libs/convex.mjs +4841 -0
- package/admin-dist/server/_libs/cookie-es.mjs +58 -0
- package/admin-dist/server/_libs/croner.mjs +1 -0
- package/admin-dist/server/_libs/crossws.mjs +1 -0
- package/admin-dist/server/_libs/date-fns.mjs +1716 -0
- package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
- package/admin-dist/server/_libs/get-nonce.mjs +9 -0
- package/admin-dist/server/_libs/h3-v2.mjs +277 -0
- package/admin-dist/server/_libs/h3.mjs +401 -0
- package/admin-dist/server/_libs/hookable.mjs +1 -0
- package/admin-dist/server/_libs/isbot.mjs +20 -0
- package/admin-dist/server/_libs/lucide-react.mjs +850 -0
- package/admin-dist/server/_libs/ohash.mjs +1 -0
- package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
- package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
- package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
- package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
- package/admin-dist/server/_libs/rou3.mjs +8 -0
- package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
- package/admin-dist/server/_libs/seroval.mjs +1765 -0
- package/admin-dist/server/_libs/srvx.mjs +719 -0
- package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
- package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
- package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
- package/admin-dist/server/_libs/tslib.mjs +39 -0
- package/admin-dist/server/_libs/ufo.mjs +54 -0
- package/admin-dist/server/_libs/unctx.mjs +1 -0
- package/admin-dist/server/_libs/unstorage.mjs +1 -0
- package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
- package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
- package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
- package/admin-dist/server/_libs/zod.mjs +4223 -0
- package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
- package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
- package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
- package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
- package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
- package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
- package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
- package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
- package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
- package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
- package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
- package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
- package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
- package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
- package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
- package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
- package/admin-dist/server/_ssr/index.mjs +1275 -0
- package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
- package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
- package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
- package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
- package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
- package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
- package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
- package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
- package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
- package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
- package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
- package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
- package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
- package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
- package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
- package/admin-dist/server/favicon.ico +0 -0
- package/admin-dist/server/index.mjs +627 -0
- package/dist/cli/index.js +0 -0
- package/dist/client/admin-config.d.ts +0 -1
- package/dist/client/admin-config.d.ts.map +1 -1
- package/dist/client/admin-config.js +0 -1
- package/dist/client/admin-config.js.map +1 -1
- package/dist/client/adminApi.d.ts.map +1 -1
- package/dist/client/agentTools.d.ts +1237 -135
- package/dist/client/agentTools.d.ts.map +1 -1
- package/dist/client/agentTools.js +33 -9
- package/dist/client/agentTools.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/mediaAssets.d.ts +35 -0
- package/dist/component/mediaAssets.d.ts.map +1 -1
- package/dist/component/mediaAssets.js +81 -0
- package/dist/component/mediaAssets.js.map +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +2 -1
- package/dist/test.js.map +1 -1
- package/package.json +9 -5
- package/dist/component/auditLog.d.ts +0 -410
- package/dist/component/auditLog.d.ts.map +0 -1
- package/dist/component/auditLog.js +0 -607
- package/dist/component/auditLog.js.map +0 -1
- package/dist/component/types.d.ts +0 -4
- package/dist/component/types.d.ts.map +0 -1
- package/dist/component/types.js +0 -2
- package/dist/component/types.js.map +0 -1
- package/src/cli/commands/admin.ts +0 -104
- package/src/cli/index.ts +0 -21
- package/src/cli/utils/detectConvexUrl.ts +0 -54
- package/src/cli/utils/openBrowser.ts +0 -16
- package/src/client/admin-config.ts +0 -138
- package/src/client/adminApi.ts +0 -942
- package/src/client/agentTools.ts +0 -1311
- package/src/client/argTypes.ts +0 -316
- package/src/client/field-types.ts +0 -187
- package/src/client/index.ts +0 -1301
- package/src/client/queryBuilder.ts +0 -1100
- package/src/client/schema/codegen.ts +0 -500
- package/src/client/schema/defineContentType.ts +0 -501
- package/src/client/schema/index.ts +0 -169
- package/src/client/schema/schemaDrift.ts +0 -574
- package/src/client/schema/typedClient.ts +0 -688
- package/src/client/schema/types.ts +0 -666
- package/src/client/types.ts +0 -723
- package/src/client/workflows.ts +0 -141
- package/src/client/wrapper.ts +0 -4304
- package/src/component/_generated/api.ts +0 -140
- package/src/component/_generated/component.ts +0 -5029
- package/src/component/_generated/dataModel.ts +0 -60
- package/src/component/_generated/server.ts +0 -156
- package/src/component/authorization.ts +0 -647
- package/src/component/authorizationHooks.ts +0 -668
- package/src/component/bulkOperations.ts +0 -687
- package/src/component/contentEntries.ts +0 -1976
- package/src/component/contentEntryMutations.ts +0 -1223
- package/src/component/contentEntryValidation.ts +0 -707
- package/src/component/contentLock.ts +0 -550
- package/src/component/contentTypeMigration.ts +0 -1064
- package/src/component/contentTypeMutations.ts +0 -969
- package/src/component/contentTypes.ts +0 -346
- package/src/component/convex.config.ts +0 -44
- package/src/component/documentTypes.ts +0 -240
- package/src/component/eventEmitter.ts +0 -485
- package/src/component/exportImport.ts +0 -1169
- package/src/component/index.ts +0 -491
- package/src/component/lib/deepReferenceResolver.ts +0 -999
- package/src/component/lib/errors.ts +0 -816
- package/src/component/lib/index.ts +0 -145
- package/src/component/lib/mediaReferenceResolver.ts +0 -495
- package/src/component/lib/metadataExtractor.ts +0 -792
- package/src/component/lib/mutationAuth.ts +0 -199
- package/src/component/lib/queries.ts +0 -79
- package/src/component/lib/ragContentChunker.ts +0 -1371
- package/src/component/lib/referenceResolver.ts +0 -430
- package/src/component/lib/slugGenerator.ts +0 -262
- package/src/component/lib/slugUniqueness.ts +0 -333
- package/src/component/lib/softDelete.ts +0 -44
- package/src/component/localeFallbackChain.ts +0 -673
- package/src/component/localeFields.ts +0 -896
- package/src/component/mediaAssetMutations.ts +0 -725
- package/src/component/mediaAssets.ts +0 -932
- package/src/component/mediaFolderMutations.ts +0 -1046
- package/src/component/mediaUploadMutations.ts +0 -224
- package/src/component/mediaVariantMutations.ts +0 -900
- package/src/component/mediaVariants.ts +0 -793
- package/src/component/ragContentIndexer.ts +0 -1067
- package/src/component/rateLimitHooks.ts +0 -572
- package/src/component/roles.ts +0 -1360
- package/src/component/scheduledPublish.ts +0 -358
- package/src/component/schema.ts +0 -617
- package/src/component/taxonomies.ts +0 -949
- package/src/component/taxonomyMutations.ts +0 -1210
- package/src/component/trash.ts +0 -724
- package/src/component/userContext.ts +0 -898
- package/src/component/validation.ts +0 -1388
- package/src/component/validators.ts +0 -949
- package/src/component/versionMutations.ts +0 -392
- package/src/component/webhookTrigger.ts +0 -1922
- package/src/react/index.ts +0 -898
- package/src/test.ts +0 -1580
|
@@ -1,1223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content Entry Mutation Functions
|
|
3
|
-
*
|
|
4
|
-
* Provides mutation functions for creating, updating, and deleting content entries.
|
|
5
|
-
* Content entries are instances of content types that hold the actual content data.
|
|
6
|
-
*
|
|
7
|
-
* Content Lifecycle:
|
|
8
|
-
* 1. Content starts as "draft" status by default
|
|
9
|
-
* 2. Draft content can be edited freely without affecting any published version
|
|
10
|
-
* 3. Publishing changes status to "published" and records publish timestamps
|
|
11
|
-
* 4. Unpublishing reverts status to "draft" for further editing
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { v } from "convex/values";
|
|
15
|
-
import { mutation } from "./_generated/server.js";
|
|
16
|
-
import {
|
|
17
|
-
contentEntryDoc,
|
|
18
|
-
createContentEntryArgs,
|
|
19
|
-
updateContentEntryArgs,
|
|
20
|
-
publishEntryArgs,
|
|
21
|
-
deleteContentEntryArgs,
|
|
22
|
-
duplicateContentEntryArgs,
|
|
23
|
-
mutationAuthContext,
|
|
24
|
-
} from "./validators.js";
|
|
25
|
-
import { generateSlug } from "./lib/slugGenerator.js";
|
|
26
|
-
import { ensureUniqueSlug } from "./lib/slugUniqueness.js";
|
|
27
|
-
import {
|
|
28
|
-
validateContentData,
|
|
29
|
-
ContentTypeSchema,
|
|
30
|
-
FieldDefinition,
|
|
31
|
-
} from "./validation.js";
|
|
32
|
-
import { validateLockForUpdate } from "./contentLock.js";
|
|
33
|
-
import {
|
|
34
|
-
emitEvent,
|
|
35
|
-
contentEntryEventType,
|
|
36
|
-
ContentEntryEventPayload,
|
|
37
|
-
} from "./eventEmitter.js";
|
|
38
|
-
import {
|
|
39
|
-
contentTypeNotFound,
|
|
40
|
-
contentTypeDeleted,
|
|
41
|
-
contentTypeInactive,
|
|
42
|
-
contentEntryNotFound,
|
|
43
|
-
contentEntryDeleted,
|
|
44
|
-
contentEntryNotDeleted,
|
|
45
|
-
contentEntryAlreadyPublished,
|
|
46
|
-
contentEntryNotPublished,
|
|
47
|
-
contentEntryArchived,
|
|
48
|
-
contentEntryValidationFailed,
|
|
49
|
-
contentEntryLocked,
|
|
50
|
-
contentEntryCreateFailed,
|
|
51
|
-
contentEntryUpdateFailed,
|
|
52
|
-
} from "./lib/errors.js";
|
|
53
|
-
import { requireMutationAuth, withResourceOwner } from "./lib/mutationAuth.js";
|
|
54
|
-
import { isDeleted } from "./lib/softDelete.js";
|
|
55
|
-
|
|
56
|
-
// =============================================================================
|
|
57
|
-
// Create Entry Mutation
|
|
58
|
-
// =============================================================================
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Mutation to create a new content entry.
|
|
62
|
-
*
|
|
63
|
-
* Content entries are created with "draft" status by default. This allows
|
|
64
|
-
* content to be edited and refined before being published to the live site.
|
|
65
|
-
*
|
|
66
|
-
* The mutation will:
|
|
67
|
-
* 1. Validate that the content type exists
|
|
68
|
-
* 2. Generate a slug from the title field (or use provided slug)
|
|
69
|
-
* 3. Ensure the slug is unique within the content type
|
|
70
|
-
* 4. Create the entry with draft status (unless specified otherwise)
|
|
71
|
-
*
|
|
72
|
-
* @param contentTypeId - The ID of the content type this entry belongs to
|
|
73
|
-
* @param data - The content data (validated against content type schema at runtime)
|
|
74
|
-
* @param slug - Optional custom slug (auto-generated from title if not provided)
|
|
75
|
-
* @param locale - Optional locale code for localized content
|
|
76
|
-
* @param primaryEntryId - Reference to primary entry if this is a localized variant
|
|
77
|
-
* @param status - Initial status (defaults to "draft")
|
|
78
|
-
* @param createdBy - Optional user ID for audit trail
|
|
79
|
-
*
|
|
80
|
-
* @returns The created content entry
|
|
81
|
-
*
|
|
82
|
-
* @throws Error if the content type does not exist
|
|
83
|
-
* @throws Error if the content type is not active
|
|
84
|
-
*
|
|
85
|
-
* @example
|
|
86
|
-
* ```typescript
|
|
87
|
-
* // Create a new blog post (starts as draft)
|
|
88
|
-
* const post = await ctx.runMutation(api.contentEntryMutations.createEntry, {
|
|
89
|
-
* contentTypeId: blogTypeId,
|
|
90
|
-
* data: {
|
|
91
|
-
* title: "My First Post",
|
|
92
|
-
* content: "<p>Hello world!</p>",
|
|
93
|
-
* },
|
|
94
|
-
* createdBy: currentUserId,
|
|
95
|
-
* });
|
|
96
|
-
*
|
|
97
|
-
* // Create with explicit status
|
|
98
|
-
* const scheduledPost = await ctx.runMutation(api.contentEntryMutations.createEntry, {
|
|
99
|
-
* contentTypeId: blogTypeId,
|
|
100
|
-
* data: { title: "Scheduled Post" },
|
|
101
|
-
* status: "scheduled",
|
|
102
|
-
* scheduledPublishAt: Date.now() + 86400000, // Tomorrow
|
|
103
|
-
* });
|
|
104
|
-
* ```
|
|
105
|
-
*/
|
|
106
|
-
export const createEntry = mutation({
|
|
107
|
-
args: {
|
|
108
|
-
...createContentEntryArgs.fields,
|
|
109
|
-
/** Optional auth context for mutation-level authorization */
|
|
110
|
-
_auth: v.optional(mutationAuthContext),
|
|
111
|
-
},
|
|
112
|
-
returns: contentEntryDoc,
|
|
113
|
-
handler: async (ctx, args) => {
|
|
114
|
-
const {
|
|
115
|
-
contentTypeId,
|
|
116
|
-
data,
|
|
117
|
-
locale,
|
|
118
|
-
primaryEntryId,
|
|
119
|
-
createdBy,
|
|
120
|
-
_auth,
|
|
121
|
-
} = args;
|
|
122
|
-
|
|
123
|
-
// Authorization check - contentEntries.create permission
|
|
124
|
-
requireMutationAuth(_auth, "contentEntries", "create");
|
|
125
|
-
|
|
126
|
-
// Validate content type exists and is active
|
|
127
|
-
const contentType = await ctx.db.get(contentTypeId);
|
|
128
|
-
if (!contentType) {
|
|
129
|
-
throw contentTypeNotFound((contentTypeId as unknown) as string);
|
|
130
|
-
}
|
|
131
|
-
if (!contentType.isActive) {
|
|
132
|
-
throw contentTypeInactive(
|
|
133
|
-
(contentTypeId as unknown) as string,
|
|
134
|
-
contentType.name,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
if (isDeleted(contentType)) {
|
|
138
|
-
throw contentTypeDeleted(
|
|
139
|
-
(contentTypeId as unknown) as string,
|
|
140
|
-
contentType.name,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Determine which field to use for slug generation
|
|
145
|
-
const slugField = contentType.slugField ?? "title";
|
|
146
|
-
const contentData = data as Record<string, unknown>;
|
|
147
|
-
|
|
148
|
-
// Build the schema for validation
|
|
149
|
-
const schema: ContentTypeSchema = {
|
|
150
|
-
name: contentType.name,
|
|
151
|
-
displayName: contentType.displayName,
|
|
152
|
-
description: contentType.description,
|
|
153
|
-
fields: contentType.fields as FieldDefinition[],
|
|
154
|
-
titleField: contentType.titleField,
|
|
155
|
-
slugField: contentType.slugField,
|
|
156
|
-
singleton: contentType.singleton,
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
// Validate content data against the content type schema
|
|
160
|
-
const validationResult = validateContentData(contentData, schema);
|
|
161
|
-
if (!validationResult.valid) {
|
|
162
|
-
throw contentEntryValidationFailed(validationResult.errors);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Generate or validate slug
|
|
166
|
-
let slug = args.slug;
|
|
167
|
-
if (!slug) {
|
|
168
|
-
// Generate slug from the slug field value
|
|
169
|
-
const slugSource = contentData[slugField];
|
|
170
|
-
if (typeof slugSource === "string" && slugSource.trim()) {
|
|
171
|
-
slug = generateSlug(slugSource);
|
|
172
|
-
} else {
|
|
173
|
-
// Fallback to "untitled" if no suitable field value
|
|
174
|
-
slug = "untitled";
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Ensure slug is unique within this content type
|
|
179
|
-
const queryFn = async (candidateSlug: string) => {
|
|
180
|
-
return await ctx.db
|
|
181
|
-
.query("contentEntries")
|
|
182
|
-
.withIndex("by_content_type_and_slug", (q) =>
|
|
183
|
-
q.eq("contentTypeId", contentTypeId).eq("slug", candidateSlug),
|
|
184
|
-
)
|
|
185
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
186
|
-
.first();
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const uniqueSlug = await ensureUniqueSlug(slug, queryFn);
|
|
190
|
-
|
|
191
|
-
// Default to draft status - content should start unpublished
|
|
192
|
-
const status = args.status ?? "draft";
|
|
193
|
-
|
|
194
|
-
// Generate searchable text from text fields
|
|
195
|
-
let searchText: string | undefined = "";
|
|
196
|
-
for (const field of contentType.fields) {
|
|
197
|
-
if (field.searchable && contentData[field.name]) {
|
|
198
|
-
const value = contentData[field.name];
|
|
199
|
-
if (typeof value === "string") {
|
|
200
|
-
searchText += ` ${value}`;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
searchText = searchText.trim() || undefined;
|
|
205
|
-
|
|
206
|
-
// Create the entry
|
|
207
|
-
const _now = Date.now();
|
|
208
|
-
const entryId = await ctx.db.insert("contentEntries", {
|
|
209
|
-
contentTypeId,
|
|
210
|
-
slug: uniqueSlug,
|
|
211
|
-
status,
|
|
212
|
-
data,
|
|
213
|
-
locale,
|
|
214
|
-
primaryEntryId,
|
|
215
|
-
version: 1,
|
|
216
|
-
createdBy,
|
|
217
|
-
updatedBy: createdBy,
|
|
218
|
-
searchText,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Retrieve and return the created entry
|
|
222
|
-
const entry = await ctx.db.get(entryId);
|
|
223
|
-
if (!entry) {
|
|
224
|
-
throw contentEntryCreateFailed((contentTypeId as unknown) as string);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Emit content entry created event
|
|
228
|
-
await emitEvent(ctx, {
|
|
229
|
-
eventType: contentEntryEventType("created"),
|
|
230
|
-
resourceType: "contentEntry",
|
|
231
|
-
resourceId: (entryId as unknown) as string,
|
|
232
|
-
action: "created",
|
|
233
|
-
payload: {
|
|
234
|
-
slug: uniqueSlug,
|
|
235
|
-
contentTypeName: contentType.name,
|
|
236
|
-
contentTypeId: (contentTypeId as unknown) as string,
|
|
237
|
-
status,
|
|
238
|
-
version: 1,
|
|
239
|
-
locale,
|
|
240
|
-
} as ContentEntryEventPayload,
|
|
241
|
-
userId: createdBy,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
return entry;
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// =============================================================================
|
|
249
|
-
// Update Entry Mutation
|
|
250
|
-
// =============================================================================
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Mutation to update an existing content entry.
|
|
254
|
-
*
|
|
255
|
-
* Re-validates content against the type schema, optionally regenerates slug,
|
|
256
|
-
* and updates the modification timestamp. Updates are allowed regardless of status:
|
|
257
|
-
* - Draft entries: All fields can be updated freely
|
|
258
|
-
* - Published entries: Updates create a new "working draft" that doesn't
|
|
259
|
-
* affect the live version until republished
|
|
260
|
-
* - Scheduled entries: Updates modify the scheduled content
|
|
261
|
-
*
|
|
262
|
-
* Key behaviors:
|
|
263
|
-
* 1. **Content Validation**: When data is provided, it's merged with existing data
|
|
264
|
-
* and validated against the content type schema. Invalid data throws an error.
|
|
265
|
-
* 2. **Slug Handling**: Explicit slug takes precedence. If `regenerateSlug` is true
|
|
266
|
-
* and data is updated, the slug is regenerated from the slugField value.
|
|
267
|
-
* 3. **Search Text**: Automatically regenerated from searchable fields when data changes.
|
|
268
|
-
* 4. **Version Tracking**: Version number is incremented on every update.
|
|
269
|
-
*
|
|
270
|
-
* @param id - The content entry ID to update
|
|
271
|
-
* @param slug - Optional new slug (uniqueness will be validated)
|
|
272
|
-
* @param data - Optional new content data (merged with existing, then validated)
|
|
273
|
-
* @param status - Optional new status
|
|
274
|
-
* @param scheduledPublishAt - Optional scheduled publish time (for "scheduled" status)
|
|
275
|
-
* @param updatedBy - Optional user ID for audit trail
|
|
276
|
-
* @param regenerateSlug - If true, regenerates slug from slugField when data is updated
|
|
277
|
-
*
|
|
278
|
-
* @returns The updated content entry
|
|
279
|
-
*
|
|
280
|
-
* @throws Error if the entry does not exist
|
|
281
|
-
* @throws Error if the entry has been deleted
|
|
282
|
-
* @throws Error if the content type has been deleted
|
|
283
|
-
* @throws Error if content validation fails
|
|
284
|
-
* @throws Error if the new slug is not unique
|
|
285
|
-
*
|
|
286
|
-
* @example
|
|
287
|
-
* ```typescript
|
|
288
|
-
* // Update content data (validates against schema)
|
|
289
|
-
* await ctx.runMutation(api.contentEntryMutations.updateEntry, {
|
|
290
|
-
* id: entryId,
|
|
291
|
-
* data: { title: "Updated Title", content: "<p>New content</p>" },
|
|
292
|
-
* updatedBy: currentUserId,
|
|
293
|
-
* });
|
|
294
|
-
*
|
|
295
|
-
* // Change slug explicitly
|
|
296
|
-
* await ctx.runMutation(api.contentEntryMutations.updateEntry, {
|
|
297
|
-
* id: entryId,
|
|
298
|
-
* slug: "new-url-slug",
|
|
299
|
-
* });
|
|
300
|
-
*
|
|
301
|
-
* // Update title and regenerate slug from it
|
|
302
|
-
* await ctx.runMutation(api.contentEntryMutations.updateEntry, {
|
|
303
|
-
* id: entryId,
|
|
304
|
-
* data: { title: "My New Blog Post Title" },
|
|
305
|
-
* regenerateSlug: true,
|
|
306
|
-
* updatedBy: currentUserId,
|
|
307
|
-
* });
|
|
308
|
-
* ```
|
|
309
|
-
*/
|
|
310
|
-
export const updateEntry = mutation({
|
|
311
|
-
args: {
|
|
312
|
-
...updateContentEntryArgs.fields,
|
|
313
|
-
/** Optional auth context for mutation-level authorization */
|
|
314
|
-
_auth: v.optional(mutationAuthContext),
|
|
315
|
-
},
|
|
316
|
-
returns: contentEntryDoc,
|
|
317
|
-
handler: async (ctx, args) => {
|
|
318
|
-
const {
|
|
319
|
-
id,
|
|
320
|
-
slug,
|
|
321
|
-
data,
|
|
322
|
-
status,
|
|
323
|
-
scheduledPublishAt,
|
|
324
|
-
updatedBy,
|
|
325
|
-
regenerateSlug,
|
|
326
|
-
_auth,
|
|
327
|
-
} = args;
|
|
328
|
-
|
|
329
|
-
const entry = await ctx.db.get(id);
|
|
330
|
-
if (!entry) {
|
|
331
|
-
throw contentEntryNotFound((id as unknown) as string);
|
|
332
|
-
}
|
|
333
|
-
if (isDeleted(entry)) {
|
|
334
|
-
throw contentEntryDeleted((id as unknown) as string);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Authorization check - contentEntries.update permission (with ownership check)
|
|
338
|
-
requireMutationAuth(
|
|
339
|
-
withResourceOwner(_auth, entry.createdBy),
|
|
340
|
-
"contentEntries",
|
|
341
|
-
"update",
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
// Check lock status - only the lock holder can update a locked entry
|
|
345
|
-
const lockValidation = validateLockForUpdate(entry, updatedBy);
|
|
346
|
-
if (!lockValidation.isAllowed) {
|
|
347
|
-
// Extract lock info from entry for detailed error
|
|
348
|
-
if (entry.lockedBy && entry.lockExpiresAt) {
|
|
349
|
-
throw contentEntryLocked(
|
|
350
|
-
(id as unknown) as string,
|
|
351
|
-
entry.lockedBy,
|
|
352
|
-
entry.lockExpiresAt,
|
|
353
|
-
updatedBy,
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
throw contentEntryLocked(
|
|
357
|
-
(id as unknown) as string,
|
|
358
|
-
"unknown",
|
|
359
|
-
Date.now(),
|
|
360
|
-
updatedBy,
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
365
|
-
if (!contentType) {
|
|
366
|
-
throw contentTypeNotFound((entry.contentTypeId as unknown) as string);
|
|
367
|
-
}
|
|
368
|
-
if (isDeleted(contentType)) {
|
|
369
|
-
throw contentTypeDeleted(
|
|
370
|
-
(entry.contentTypeId as unknown) as string,
|
|
371
|
-
contentType.name,
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Build the update object
|
|
376
|
-
const updates: Record<string, unknown> = {
|
|
377
|
-
updatedBy,
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
// Merge data if provided, otherwise use existing data
|
|
381
|
-
let mergedData: Record<string, unknown>;
|
|
382
|
-
if (data !== undefined) {
|
|
383
|
-
mergedData = { ...(entry.data as Record<string, unknown>), ...data };
|
|
384
|
-
} else {
|
|
385
|
-
mergedData = entry.data as Record<string, unknown>;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Validate content data against the content type schema
|
|
389
|
-
if (data !== undefined) {
|
|
390
|
-
const schema: ContentTypeSchema = {
|
|
391
|
-
name: contentType.name,
|
|
392
|
-
displayName: contentType.displayName,
|
|
393
|
-
description: contentType.description,
|
|
394
|
-
fields: contentType.fields as FieldDefinition[],
|
|
395
|
-
titleField: contentType.titleField,
|
|
396
|
-
slugField: contentType.slugField,
|
|
397
|
-
singleton: contentType.singleton,
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
const validationResult = validateContentData(mergedData, schema);
|
|
401
|
-
if (!validationResult.valid) {
|
|
402
|
-
throw contentEntryValidationFailed(validationResult.errors);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
updates.data = mergedData;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Helper function for slug uniqueness queries
|
|
409
|
-
const slugQueryFn = async (candidateSlug: string) => {
|
|
410
|
-
const existing = await ctx.db
|
|
411
|
-
.query("contentEntries")
|
|
412
|
-
.withIndex("by_content_type_and_slug", (q) =>
|
|
413
|
-
q.eq("contentTypeId", entry.contentTypeId).eq("slug", candidateSlug),
|
|
414
|
-
)
|
|
415
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
416
|
-
.first();
|
|
417
|
-
// Exclude current entry from uniqueness check
|
|
418
|
-
if (existing && existing._id !== id) {
|
|
419
|
-
return existing;
|
|
420
|
-
}
|
|
421
|
-
return null;
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
// Handle slug: explicit slug takes precedence, then regeneration if requested
|
|
425
|
-
if (slug !== undefined && slug !== entry.slug) {
|
|
426
|
-
// Explicit slug provided - validate and ensure uniqueness
|
|
427
|
-
const uniqueSlug = await ensureUniqueSlug(slug, slugQueryFn, {
|
|
428
|
-
excludeEntryId: (id as unknown) as string,
|
|
429
|
-
});
|
|
430
|
-
updates.slug = uniqueSlug;
|
|
431
|
-
} else if (regenerateSlug && data !== undefined) {
|
|
432
|
-
// Regenerate slug from the slug field value
|
|
433
|
-
const slugField = contentType.slugField ?? "title";
|
|
434
|
-
const slugSource = mergedData[slugField];
|
|
435
|
-
|
|
436
|
-
if (typeof slugSource === "string" && slugSource.trim()) {
|
|
437
|
-
const newSlug = generateSlug(slugSource);
|
|
438
|
-
// Only update if the regenerated slug is different from current
|
|
439
|
-
if (newSlug !== entry.slug) {
|
|
440
|
-
const uniqueSlug = await ensureUniqueSlug(newSlug, slugQueryFn, {
|
|
441
|
-
excludeEntryId: (id as unknown) as string,
|
|
442
|
-
});
|
|
443
|
-
updates.slug = uniqueSlug;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Update search text if data changed
|
|
449
|
-
if (data !== undefined) {
|
|
450
|
-
let searchText = "";
|
|
451
|
-
for (const field of contentType.fields) {
|
|
452
|
-
if (field.searchable && mergedData[field.name]) {
|
|
453
|
-
const value = mergedData[field.name];
|
|
454
|
-
if (typeof value === "string") {
|
|
455
|
-
searchText += ` ${value}`;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
updates.searchText = searchText.trim() || undefined;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Handle status update
|
|
463
|
-
if (status !== undefined) {
|
|
464
|
-
updates.status = status;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Handle scheduled publish time
|
|
468
|
-
if (scheduledPublishAt !== undefined) {
|
|
469
|
-
updates.scheduledPublishAt = scheduledPublishAt;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Check if content has changed to determine if we need a version snapshot
|
|
473
|
-
const hasDataChanges =
|
|
474
|
-
data !== undefined &&
|
|
475
|
-
JSON.stringify(entry.data) !== JSON.stringify(mergedData);
|
|
476
|
-
const hasSlugChanges = updates.slug !== undefined;
|
|
477
|
-
|
|
478
|
-
// Create a version snapshot before updating if content changed
|
|
479
|
-
if (hasDataChanges || hasSlugChanges) {
|
|
480
|
-
await ctx.db.insert("contentVersions", {
|
|
481
|
-
entryId: id,
|
|
482
|
-
versionNumber: entry.version,
|
|
483
|
-
data: entry.data,
|
|
484
|
-
slug: entry.slug,
|
|
485
|
-
status: entry.status,
|
|
486
|
-
changeDescription: "Draft saved",
|
|
487
|
-
createdBy: updatedBy,
|
|
488
|
-
wasPublished: false,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Increment version number
|
|
493
|
-
updates.version = entry.version + 1;
|
|
494
|
-
|
|
495
|
-
// Apply updates
|
|
496
|
-
await ctx.db.patch(id, updates);
|
|
497
|
-
|
|
498
|
-
const updatedEntry = await ctx.db.get(id);
|
|
499
|
-
if (!updatedEntry) {
|
|
500
|
-
throw contentEntryUpdateFailed((id as unknown) as string);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Emit content entry updated event
|
|
504
|
-
await emitEvent(ctx, {
|
|
505
|
-
eventType: contentEntryEventType("updated"),
|
|
506
|
-
resourceType: "contentEntry",
|
|
507
|
-
resourceId: (id as unknown) as string,
|
|
508
|
-
action: "updated",
|
|
509
|
-
payload: {
|
|
510
|
-
slug: updatedEntry.slug,
|
|
511
|
-
contentTypeName: contentType.name,
|
|
512
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
513
|
-
status: updatedEntry.status,
|
|
514
|
-
version: updatedEntry.version,
|
|
515
|
-
locale: updatedEntry.locale,
|
|
516
|
-
} as ContentEntryEventPayload,
|
|
517
|
-
userId: updatedBy,
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
return updatedEntry;
|
|
521
|
-
},
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// =============================================================================
|
|
525
|
-
// Publish Entry Mutation
|
|
526
|
-
// =============================================================================
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Mutation to publish a content entry.
|
|
530
|
-
*
|
|
531
|
-
* Publishing transitions an entry from "draft" (or "scheduled") to "published"
|
|
532
|
-
* status, making it visible on the live site.
|
|
533
|
-
*
|
|
534
|
-
* When publishing:
|
|
535
|
-
* - Status is set to "published"
|
|
536
|
-
* - firstPublishedAt is set if this is the first publication
|
|
537
|
-
* - lastPublishedAt is updated to current timestamp
|
|
538
|
-
* - Version is incremented
|
|
539
|
-
* - A version snapshot can be created (if versioning is enabled)
|
|
540
|
-
*
|
|
541
|
-
* @param id - The content entry ID to publish
|
|
542
|
-
* @param changeDescription - Optional description of changes (for version history)
|
|
543
|
-
* @param updatedBy - Optional user ID for audit trail
|
|
544
|
-
*
|
|
545
|
-
* @returns The published content entry
|
|
546
|
-
*
|
|
547
|
-
* @throws Error if the entry does not exist
|
|
548
|
-
* @throws Error if the entry has been deleted
|
|
549
|
-
* @throws Error if the entry is already published
|
|
550
|
-
*
|
|
551
|
-
* @example
|
|
552
|
-
* ```typescript
|
|
553
|
-
* const published = await ctx.runMutation(api.contentEntryMutations.publishEntry, {
|
|
554
|
-
* id: entryId,
|
|
555
|
-
* changeDescription: "Initial publication",
|
|
556
|
-
* updatedBy: currentUserId,
|
|
557
|
-
* });
|
|
558
|
-
* ```
|
|
559
|
-
*/
|
|
560
|
-
export const publishEntry = mutation({
|
|
561
|
-
args: {
|
|
562
|
-
...publishEntryArgs.fields,
|
|
563
|
-
/** Optional auth context for mutation-level authorization */
|
|
564
|
-
_auth: v.optional(mutationAuthContext),
|
|
565
|
-
},
|
|
566
|
-
returns: contentEntryDoc,
|
|
567
|
-
handler: async (ctx, args) => {
|
|
568
|
-
const { id, changeDescription, updatedBy, _auth } = args;
|
|
569
|
-
|
|
570
|
-
const entry = await ctx.db.get(id);
|
|
571
|
-
if (!entry) {
|
|
572
|
-
throw contentEntryNotFound((id as unknown) as string);
|
|
573
|
-
}
|
|
574
|
-
if (isDeleted(entry)) {
|
|
575
|
-
throw contentEntryDeleted((id as unknown) as string);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Authorization check - contentEntries.publish permission (with ownership check)
|
|
579
|
-
requireMutationAuth(
|
|
580
|
-
withResourceOwner(_auth, entry.createdBy),
|
|
581
|
-
"contentEntries",
|
|
582
|
-
"publish",
|
|
583
|
-
);
|
|
584
|
-
|
|
585
|
-
if (entry.status === "published") {
|
|
586
|
-
throw contentEntryAlreadyPublished((id as unknown) as string);
|
|
587
|
-
}
|
|
588
|
-
if (entry.status === "archived") {
|
|
589
|
-
throw contentEntryArchived((id as unknown) as string);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const now = Date.now();
|
|
593
|
-
|
|
594
|
-
// Create a version snapshot before publishing
|
|
595
|
-
await ctx.db.insert("contentVersions", {
|
|
596
|
-
entryId: id,
|
|
597
|
-
versionNumber: entry.version,
|
|
598
|
-
data: entry.data,
|
|
599
|
-
slug: entry.slug,
|
|
600
|
-
status: entry.status,
|
|
601
|
-
changeDescription,
|
|
602
|
-
createdBy: updatedBy,
|
|
603
|
-
wasPublished: true,
|
|
604
|
-
publishedAt: now,
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
// Update the entry to published status
|
|
608
|
-
const updates: Record<string, unknown> = {
|
|
609
|
-
status: "published",
|
|
610
|
-
lastPublishedAt: now,
|
|
611
|
-
version: entry.version + 1,
|
|
612
|
-
updatedBy,
|
|
613
|
-
// Clear scheduled publish time if it was set
|
|
614
|
-
scheduledPublishAt: undefined,
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
// Set firstPublishedAt only on first publication
|
|
618
|
-
if (entry.firstPublishedAt === undefined) {
|
|
619
|
-
updates.firstPublishedAt = now;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
await ctx.db.patch(id, updates);
|
|
623
|
-
|
|
624
|
-
const publishedEntry = await ctx.db.get(id);
|
|
625
|
-
if (!publishedEntry) {
|
|
626
|
-
throw contentEntryUpdateFailed((id as unknown) as string);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
630
|
-
|
|
631
|
-
// Emit content entry published event
|
|
632
|
-
await emitEvent(ctx, {
|
|
633
|
-
eventType: contentEntryEventType("published"),
|
|
634
|
-
resourceType: "contentEntry",
|
|
635
|
-
resourceId: (id as unknown) as string,
|
|
636
|
-
action: "published",
|
|
637
|
-
payload: {
|
|
638
|
-
slug: publishedEntry.slug,
|
|
639
|
-
contentTypeName: contentType?.name ?? "unknown",
|
|
640
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
641
|
-
status: "published",
|
|
642
|
-
version: publishedEntry.version,
|
|
643
|
-
locale: publishedEntry.locale,
|
|
644
|
-
changeDescription,
|
|
645
|
-
} as ContentEntryEventPayload,
|
|
646
|
-
userId: updatedBy,
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
return publishedEntry;
|
|
650
|
-
},
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
// =============================================================================
|
|
654
|
-
// Unpublish Entry Mutation
|
|
655
|
-
// =============================================================================
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* Mutation to unpublish a content entry (revert to draft).
|
|
659
|
-
*
|
|
660
|
-
* Unpublishing transitions an entry from "published" back to "draft" status,
|
|
661
|
-
* removing it from the live site while preserving all content for further editing.
|
|
662
|
-
*
|
|
663
|
-
* This is useful for:
|
|
664
|
-
* - Taking content offline temporarily
|
|
665
|
-
* - Making significant changes before republishing
|
|
666
|
-
* - Seasonal content that needs to be hidden
|
|
667
|
-
*
|
|
668
|
-
* @param id - The content entry ID to unpublish
|
|
669
|
-
* @param updatedBy - Optional user ID for audit trail
|
|
670
|
-
*
|
|
671
|
-
* @returns The unpublished content entry (now in draft status)
|
|
672
|
-
*
|
|
673
|
-
* @throws Error if the entry does not exist
|
|
674
|
-
* @throws Error if the entry has been deleted
|
|
675
|
-
* @throws Error if the entry is not currently published
|
|
676
|
-
*
|
|
677
|
-
* @example
|
|
678
|
-
* ```typescript
|
|
679
|
-
* const draft = await ctx.runMutation(api.contentEntryMutations.unpublishEntry, {
|
|
680
|
-
* id: entryId,
|
|
681
|
-
* updatedBy: currentUserId,
|
|
682
|
-
* });
|
|
683
|
-
* console.log(draft.status); // "draft"
|
|
684
|
-
* ```
|
|
685
|
-
*/
|
|
686
|
-
export const unpublishEntry = mutation({
|
|
687
|
-
args: {
|
|
688
|
-
/** The ID of the content entry to unpublish */
|
|
689
|
-
id: v.id("contentEntries"),
|
|
690
|
-
/** User ID performing the unpublish (for audit trail) */
|
|
691
|
-
updatedBy: v.optional(v.string()),
|
|
692
|
-
/** Optional auth context for mutation-level authorization */
|
|
693
|
-
_auth: v.optional(mutationAuthContext),
|
|
694
|
-
},
|
|
695
|
-
returns: contentEntryDoc,
|
|
696
|
-
handler: async (ctx, args) => {
|
|
697
|
-
const { id, updatedBy, _auth } = args;
|
|
698
|
-
|
|
699
|
-
const entry = await ctx.db.get(id);
|
|
700
|
-
if (!entry) {
|
|
701
|
-
throw contentEntryNotFound((id as unknown) as string);
|
|
702
|
-
}
|
|
703
|
-
if (isDeleted(entry)) {
|
|
704
|
-
throw contentEntryDeleted((id as unknown) as string);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Authorization check - contentEntries.unpublish permission (with ownership check)
|
|
708
|
-
requireMutationAuth(
|
|
709
|
-
withResourceOwner(_auth, entry.createdBy),
|
|
710
|
-
"contentEntries",
|
|
711
|
-
"unpublish",
|
|
712
|
-
);
|
|
713
|
-
|
|
714
|
-
if (entry.status !== "published") {
|
|
715
|
-
throw contentEntryNotPublished((id as unknown) as string, entry.status);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
await ctx.db.patch(id, {
|
|
719
|
-
status: "draft",
|
|
720
|
-
version: entry.version + 1,
|
|
721
|
-
updatedBy,
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
const unpublishedEntry = await ctx.db.get(id);
|
|
725
|
-
if (!unpublishedEntry) {
|
|
726
|
-
throw contentEntryUpdateFailed((id as unknown) as string);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
730
|
-
|
|
731
|
-
// Emit content entry unpublished event
|
|
732
|
-
await emitEvent(ctx, {
|
|
733
|
-
eventType: contentEntryEventType("unpublished"),
|
|
734
|
-
resourceType: "contentEntry",
|
|
735
|
-
resourceId: (id as unknown) as string,
|
|
736
|
-
action: "unpublished",
|
|
737
|
-
payload: {
|
|
738
|
-
slug: unpublishedEntry.slug,
|
|
739
|
-
contentTypeName: contentType?.name ?? "unknown",
|
|
740
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
741
|
-
status: "draft",
|
|
742
|
-
version: unpublishedEntry.version,
|
|
743
|
-
locale: unpublishedEntry.locale,
|
|
744
|
-
} as ContentEntryEventPayload,
|
|
745
|
-
userId: updatedBy,
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
return unpublishedEntry;
|
|
749
|
-
},
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
// =============================================================================
|
|
753
|
-
// Delete Entry Mutation
|
|
754
|
-
// =============================================================================
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Result type for delete operations.
|
|
758
|
-
* Returns the deleted entry with updated deletedAt timestamp.
|
|
759
|
-
*/
|
|
760
|
-
const deleteResultDoc = v.object({
|
|
761
|
-
...contentEntryDoc.fields,
|
|
762
|
-
/** Number of associated versions that were cleaned up */
|
|
763
|
-
deletedVersionsCount: v.optional(v.number()),
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Mutation to delete a content entry.
|
|
768
|
-
*
|
|
769
|
-
* By default, performs a soft delete by setting the `deletedAt` timestamp.
|
|
770
|
-
* This allows the entry to be recovered later if needed.
|
|
771
|
-
*
|
|
772
|
-
* When `hardDelete` is true, permanently removes the entry and all
|
|
773
|
-
* associated version snapshots from the database.
|
|
774
|
-
*
|
|
775
|
-
* @param id - The content entry ID to delete
|
|
776
|
-
* @param deletedBy - Optional user ID for audit trail
|
|
777
|
-
* @param hardDelete - If true, permanently deletes entry and versions (default: false)
|
|
778
|
-
*
|
|
779
|
-
* @returns The deleted content entry (with deletedAt set for soft deletes)
|
|
780
|
-
*
|
|
781
|
-
* @throws Error if the entry does not exist
|
|
782
|
-
* @throws Error if the entry has already been deleted (for soft deletes)
|
|
783
|
-
*
|
|
784
|
-
* @example
|
|
785
|
-
* ```typescript
|
|
786
|
-
* // Soft delete (default) - entry can be recovered
|
|
787
|
-
* const deleted = await ctx.runMutation(api.contentEntryMutations.deleteEntry, {
|
|
788
|
-
* id: entryId,
|
|
789
|
-
* deletedBy: currentUserId,
|
|
790
|
-
* });
|
|
791
|
-
*
|
|
792
|
-
* // Hard delete - permanently removes entry and all versions
|
|
793
|
-
* await ctx.runMutation(api.contentEntryMutations.deleteEntry, {
|
|
794
|
-
* id: entryId,
|
|
795
|
-
* deletedBy: currentUserId,
|
|
796
|
-
* hardDelete: true,
|
|
797
|
-
* });
|
|
798
|
-
* ```
|
|
799
|
-
*/
|
|
800
|
-
export const deleteEntry = mutation({
|
|
801
|
-
args: {
|
|
802
|
-
...deleteContentEntryArgs.fields,
|
|
803
|
-
/** Optional auth context for mutation-level authorization */
|
|
804
|
-
_auth: v.optional(mutationAuthContext),
|
|
805
|
-
},
|
|
806
|
-
returns: deleteResultDoc,
|
|
807
|
-
handler: async (ctx, args) => {
|
|
808
|
-
const { id, deletedBy, hardDelete = false, _auth } = args;
|
|
809
|
-
|
|
810
|
-
const entry = await ctx.db.get(id);
|
|
811
|
-
|
|
812
|
-
if (!entry) {
|
|
813
|
-
throw contentEntryNotFound((id as unknown) as string);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Authorization check - contentEntries.delete permission (with ownership check)
|
|
817
|
-
requireMutationAuth(
|
|
818
|
-
withResourceOwner(_auth, entry.createdBy),
|
|
819
|
-
"contentEntries",
|
|
820
|
-
"delete",
|
|
821
|
-
);
|
|
822
|
-
|
|
823
|
-
// For soft delete, check if already deleted
|
|
824
|
-
if (!hardDelete && isDeleted(entry)) {
|
|
825
|
-
throw contentEntryDeleted((id as unknown) as string);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Get all associated versions for this entry
|
|
829
|
-
const versions = await ctx.db
|
|
830
|
-
.query("contentVersions")
|
|
831
|
-
.withIndex("by_entry", (q) => q.eq("entryId", id))
|
|
832
|
-
.collect();
|
|
833
|
-
|
|
834
|
-
const deletedVersionsCount = versions.length;
|
|
835
|
-
|
|
836
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
837
|
-
|
|
838
|
-
if (hardDelete) {
|
|
839
|
-
// Hard delete: permanently remove all versions
|
|
840
|
-
for (const version of versions) {
|
|
841
|
-
await ctx.db.delete(version._id);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// Permanently delete the entry itself
|
|
845
|
-
await ctx.db.delete(id);
|
|
846
|
-
|
|
847
|
-
// Emit content entry deleted event (for hard delete)
|
|
848
|
-
await emitEvent(ctx, {
|
|
849
|
-
eventType: contentEntryEventType("deleted"),
|
|
850
|
-
resourceType: "contentEntry",
|
|
851
|
-
resourceId: (id as unknown) as string,
|
|
852
|
-
action: "deleted",
|
|
853
|
-
payload: {
|
|
854
|
-
slug: entry.slug,
|
|
855
|
-
contentTypeName: contentType?.name ?? "unknown",
|
|
856
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
857
|
-
status: entry.status,
|
|
858
|
-
version: entry.version,
|
|
859
|
-
locale: entry.locale,
|
|
860
|
-
} as ContentEntryEventPayload,
|
|
861
|
-
userId: deletedBy,
|
|
862
|
-
metadata: { hardDelete: true },
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
// Return the entry as it was before deletion
|
|
866
|
-
return {
|
|
867
|
-
...entry,
|
|
868
|
-
deletedAt: Date.now(),
|
|
869
|
-
updatedBy: deletedBy,
|
|
870
|
-
deletedVersionsCount,
|
|
871
|
-
};
|
|
872
|
-
} else {
|
|
873
|
-
// Soft delete: set deletedAt timestamp
|
|
874
|
-
const now = Date.now();
|
|
875
|
-
|
|
876
|
-
await ctx.db.patch(id, {
|
|
877
|
-
deletedAt: now,
|
|
878
|
-
updatedBy: deletedBy,
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
// Emit content entry deleted event (for soft delete)
|
|
882
|
-
await emitEvent(ctx, {
|
|
883
|
-
eventType: contentEntryEventType("deleted"),
|
|
884
|
-
resourceType: "contentEntry",
|
|
885
|
-
resourceId: (id as unknown) as string,
|
|
886
|
-
action: "deleted",
|
|
887
|
-
payload: {
|
|
888
|
-
slug: entry.slug,
|
|
889
|
-
contentTypeName: contentType?.name ?? "unknown",
|
|
890
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
891
|
-
status: entry.status,
|
|
892
|
-
version: entry.version,
|
|
893
|
-
locale: entry.locale,
|
|
894
|
-
} as ContentEntryEventPayload,
|
|
895
|
-
userId: deletedBy,
|
|
896
|
-
metadata: { hardDelete: false },
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
return {
|
|
900
|
-
...entry,
|
|
901
|
-
deletedAt: now,
|
|
902
|
-
updatedBy: deletedBy ?? entry.updatedBy,
|
|
903
|
-
deletedVersionsCount,
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
},
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
/**
|
|
910
|
-
* Mutation to restore a soft-deleted content entry.
|
|
911
|
-
*
|
|
912
|
-
* Removes the `deletedAt` timestamp from the entry, making it active again.
|
|
913
|
-
* Only works for soft-deleted entries; hard-deleted entries cannot be recovered.
|
|
914
|
-
*
|
|
915
|
-
* @param id - The content entry ID to restore
|
|
916
|
-
* @param restoredBy - Optional user ID for audit trail
|
|
917
|
-
*
|
|
918
|
-
* @returns The restored content entry
|
|
919
|
-
*
|
|
920
|
-
* @throws Error if the entry does not exist
|
|
921
|
-
* @throws Error if the entry is not soft-deleted
|
|
922
|
-
*
|
|
923
|
-
* @example
|
|
924
|
-
* ```typescript
|
|
925
|
-
* const restored = await ctx.runMutation(api.contentEntryMutations.restoreEntry, {
|
|
926
|
-
* id: entryId,
|
|
927
|
-
* restoredBy: currentUserId,
|
|
928
|
-
* });
|
|
929
|
-
* ```
|
|
930
|
-
*/
|
|
931
|
-
export const restoreEntry = mutation({
|
|
932
|
-
args: {
|
|
933
|
-
/** The ID of the content entry to restore */
|
|
934
|
-
id: v.id("contentEntries"),
|
|
935
|
-
/** User ID performing the restoration (for audit trail) */
|
|
936
|
-
restoredBy: v.optional(v.string()),
|
|
937
|
-
/** Optional auth context for mutation-level authorization */
|
|
938
|
-
_auth: v.optional(mutationAuthContext),
|
|
939
|
-
},
|
|
940
|
-
returns: contentEntryDoc,
|
|
941
|
-
handler: async (ctx, args) => {
|
|
942
|
-
const { id, restoredBy, _auth } = args;
|
|
943
|
-
|
|
944
|
-
const entry = await ctx.db.get(id);
|
|
945
|
-
|
|
946
|
-
if (!entry) {
|
|
947
|
-
throw contentEntryNotFound((id as unknown) as string);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Authorization check - contentEntries.restore permission (with ownership check)
|
|
951
|
-
requireMutationAuth(
|
|
952
|
-
withResourceOwner(_auth, entry.createdBy),
|
|
953
|
-
"contentEntries",
|
|
954
|
-
"restore",
|
|
955
|
-
);
|
|
956
|
-
|
|
957
|
-
if (!isDeleted(entry)) {
|
|
958
|
-
throw contentEntryNotDeleted((id as unknown) as string);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// Remove the deletedAt marker to restore the entry
|
|
962
|
-
await ctx.db.patch(id, {
|
|
963
|
-
deletedAt: undefined,
|
|
964
|
-
updatedBy: restoredBy,
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
968
|
-
|
|
969
|
-
// Emit content entry restored event
|
|
970
|
-
await emitEvent(ctx, {
|
|
971
|
-
eventType: contentEntryEventType("restored"),
|
|
972
|
-
resourceType: "contentEntry",
|
|
973
|
-
resourceId: (id as unknown) as string,
|
|
974
|
-
action: "restored",
|
|
975
|
-
payload: {
|
|
976
|
-
slug: entry.slug,
|
|
977
|
-
contentTypeName: contentType?.name ?? "unknown",
|
|
978
|
-
contentTypeId: (entry.contentTypeId as unknown) as string,
|
|
979
|
-
status: entry.status,
|
|
980
|
-
version: entry.version,
|
|
981
|
-
locale: entry.locale,
|
|
982
|
-
} as ContentEntryEventPayload,
|
|
983
|
-
userId: restoredBy,
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
return {
|
|
987
|
-
...entry,
|
|
988
|
-
deletedAt: undefined,
|
|
989
|
-
updatedBy: restoredBy ?? entry.updatedBy,
|
|
990
|
-
};
|
|
991
|
-
},
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
// =============================================================================
|
|
995
|
-
// Duplicate Entry Mutation
|
|
996
|
-
// =============================================================================
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Mutation to duplicate (clone) an existing content entry.
|
|
1000
|
-
*
|
|
1001
|
-
* Creates a new content entry with the same data as the source entry,
|
|
1002
|
-
* but with a new unique slug. The duplicated entry is always created
|
|
1003
|
-
* as a draft, regardless of the source entry's status.
|
|
1004
|
-
*
|
|
1005
|
-
* This is useful for:
|
|
1006
|
-
* - Content templating workflows (copy a template to create new content)
|
|
1007
|
-
* - Creating localized variants of content
|
|
1008
|
-
* - Quick duplication of similar content pieces
|
|
1009
|
-
*
|
|
1010
|
-
* Key behaviors:
|
|
1011
|
-
* 1. **Data Cloning**: All content data is deep-copied to the new entry
|
|
1012
|
-
* 2. **Media References**: By default, media references (IDs) are copied,
|
|
1013
|
-
* pointing to the same media assets. Set `copyMediaReferences: false`
|
|
1014
|
-
* to clear media fields in the duplicate.
|
|
1015
|
-
* 3. **Slug Generation**: A new unique slug is generated from the source
|
|
1016
|
-
* entry's slug (e.g., "my-post" → "my-post-1") unless a custom slug
|
|
1017
|
-
* is provided.
|
|
1018
|
-
* 4. **Status Reset**: The duplicate always starts as "draft" with version 1
|
|
1019
|
-
* 5. **Timestamps Reset**: Publishing timestamps are cleared in the duplicate
|
|
1020
|
-
*
|
|
1021
|
-
* @param sourceEntryId - The ID of the content entry to duplicate
|
|
1022
|
-
* @param slug - Optional custom slug (auto-generated if not provided)
|
|
1023
|
-
* @param copyMediaReferences - Whether to copy media IDs (default: true)
|
|
1024
|
-
* @param locale - Optional locale for the duplicated entry
|
|
1025
|
-
* @param createdBy - Optional user ID for audit trail
|
|
1026
|
-
*
|
|
1027
|
-
* @returns The newly created duplicate content entry
|
|
1028
|
-
*
|
|
1029
|
-
* @throws Error if the source entry does not exist
|
|
1030
|
-
* @throws Error if the source entry has been deleted
|
|
1031
|
-
* @throws Error if the content type does not exist or is not active
|
|
1032
|
-
*
|
|
1033
|
-
* @example
|
|
1034
|
-
* ```typescript
|
|
1035
|
-
* // Simple duplication (keeps all media references)
|
|
1036
|
-
* const duplicate = await ctx.runMutation(api.contentEntryMutations.duplicateEntry, {
|
|
1037
|
-
* sourceEntryId: originalPostId,
|
|
1038
|
-
* createdBy: currentUserId,
|
|
1039
|
-
* });
|
|
1040
|
-
*
|
|
1041
|
-
* // Duplicate with custom slug
|
|
1042
|
-
* const duplicate = await ctx.runMutation(api.contentEntryMutations.duplicateEntry, {
|
|
1043
|
-
* sourceEntryId: templateId,
|
|
1044
|
-
* slug: "new-post-from-template",
|
|
1045
|
-
* createdBy: currentUserId,
|
|
1046
|
-
* });
|
|
1047
|
-
*
|
|
1048
|
-
* // Duplicate without media references (for a fresh start)
|
|
1049
|
-
* const duplicate = await ctx.runMutation(api.contentEntryMutations.duplicateEntry, {
|
|
1050
|
-
* sourceEntryId: originalPostId,
|
|
1051
|
-
* copyMediaReferences: false,
|
|
1052
|
-
* createdBy: currentUserId,
|
|
1053
|
-
* });
|
|
1054
|
-
* ```
|
|
1055
|
-
*/
|
|
1056
|
-
export const duplicateEntry = mutation({
|
|
1057
|
-
args: {
|
|
1058
|
-
...duplicateContentEntryArgs.fields,
|
|
1059
|
-
/** Optional auth context for mutation-level authorization */
|
|
1060
|
-
_auth: v.optional(mutationAuthContext),
|
|
1061
|
-
},
|
|
1062
|
-
returns: contentEntryDoc,
|
|
1063
|
-
handler: async (ctx, args) => {
|
|
1064
|
-
const {
|
|
1065
|
-
sourceEntryId,
|
|
1066
|
-
slug,
|
|
1067
|
-
copyMediaReferences = true,
|
|
1068
|
-
locale,
|
|
1069
|
-
createdBy,
|
|
1070
|
-
_auth,
|
|
1071
|
-
} = args;
|
|
1072
|
-
|
|
1073
|
-
// Authorization check - contentEntries.create permission (duplicate creates a new entry)
|
|
1074
|
-
requireMutationAuth(_auth, "contentEntries", "create");
|
|
1075
|
-
|
|
1076
|
-
const sourceEntry = await ctx.db.get(sourceEntryId);
|
|
1077
|
-
if (!sourceEntry) {
|
|
1078
|
-
throw contentEntryNotFound((sourceEntryId as unknown) as string);
|
|
1079
|
-
}
|
|
1080
|
-
if (isDeleted(sourceEntry)) {
|
|
1081
|
-
throw contentEntryDeleted((sourceEntryId as unknown) as string);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Retrieve and validate the content type
|
|
1085
|
-
const contentType = await ctx.db.get(sourceEntry.contentTypeId);
|
|
1086
|
-
if (!contentType) {
|
|
1087
|
-
throw contentTypeNotFound(
|
|
1088
|
-
(sourceEntry.contentTypeId as unknown) as string,
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
if (!contentType.isActive) {
|
|
1092
|
-
throw contentTypeInactive(
|
|
1093
|
-
(sourceEntry.contentTypeId as unknown) as string,
|
|
1094
|
-
contentType.name,
|
|
1095
|
-
);
|
|
1096
|
-
}
|
|
1097
|
-
if (isDeleted(contentType)) {
|
|
1098
|
-
throw contentTypeDeleted(
|
|
1099
|
-
(sourceEntry.contentTypeId as unknown) as string,
|
|
1100
|
-
contentType.name,
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Deep copy the content data
|
|
1105
|
-
const newData: Record<string, unknown> = JSON.parse(
|
|
1106
|
-
JSON.stringify(sourceEntry.data),
|
|
1107
|
-
);
|
|
1108
|
-
|
|
1109
|
-
// Optionally clear media references
|
|
1110
|
-
if (!copyMediaReferences) {
|
|
1111
|
-
const fields = contentType.fields as FieldDefinition[];
|
|
1112
|
-
for (const field of fields) {
|
|
1113
|
-
if (field.type === "media" && newData[field.name] !== undefined) {
|
|
1114
|
-
// Clear media field - set to null for single, empty array for multiple
|
|
1115
|
-
const isMultiple = field.options?.multiple;
|
|
1116
|
-
newData[field.name] = isMultiple ? [] : null;
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// Build the schema for validation
|
|
1122
|
-
const schema: ContentTypeSchema = {
|
|
1123
|
-
name: contentType.name,
|
|
1124
|
-
displayName: contentType.displayName,
|
|
1125
|
-
description: contentType.description,
|
|
1126
|
-
fields: contentType.fields as FieldDefinition[],
|
|
1127
|
-
titleField: contentType.titleField,
|
|
1128
|
-
slugField: contentType.slugField,
|
|
1129
|
-
singleton: contentType.singleton,
|
|
1130
|
-
};
|
|
1131
|
-
|
|
1132
|
-
// Validate the cloned data against the content type schema
|
|
1133
|
-
const validationResult = validateContentData(newData, schema);
|
|
1134
|
-
if (!validationResult.valid) {
|
|
1135
|
-
throw contentEntryValidationFailed(validationResult.errors);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
// Generate or validate slug
|
|
1139
|
-
let targetSlug = slug;
|
|
1140
|
-
if (!targetSlug) {
|
|
1141
|
-
// Generate a slug based on the source entry's slug
|
|
1142
|
-
// This will result in something like "original-slug-1" if "original-slug" exists
|
|
1143
|
-
targetSlug = sourceEntry.slug;
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// Ensure slug is unique within this content type
|
|
1147
|
-
const queryFn = async (candidateSlug: string) => {
|
|
1148
|
-
return await ctx.db
|
|
1149
|
-
.query("contentEntries")
|
|
1150
|
-
.withIndex("by_content_type_and_slug", (q) =>
|
|
1151
|
-
q
|
|
1152
|
-
.eq("contentTypeId", sourceEntry.contentTypeId)
|
|
1153
|
-
.eq("slug", candidateSlug),
|
|
1154
|
-
)
|
|
1155
|
-
.filter((q) => q.eq(q.field("deletedAt"), undefined))
|
|
1156
|
-
.first();
|
|
1157
|
-
};
|
|
1158
|
-
|
|
1159
|
-
const uniqueSlug = await ensureUniqueSlug(targetSlug, queryFn);
|
|
1160
|
-
|
|
1161
|
-
// Generate searchable text from text fields
|
|
1162
|
-
let searchText: string | undefined = "";
|
|
1163
|
-
for (const field of contentType.fields) {
|
|
1164
|
-
if (field.searchable && newData[field.name]) {
|
|
1165
|
-
const value = newData[field.name];
|
|
1166
|
-
if (typeof value === "string") {
|
|
1167
|
-
searchText += ` ${value}`;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
searchText = searchText.trim() || undefined;
|
|
1172
|
-
|
|
1173
|
-
// Create the duplicate entry (always as draft with version 1)
|
|
1174
|
-
const entryId = await ctx.db.insert("contentEntries", {
|
|
1175
|
-
contentTypeId: sourceEntry.contentTypeId,
|
|
1176
|
-
slug: uniqueSlug,
|
|
1177
|
-
status: "draft",
|
|
1178
|
-
data: newData,
|
|
1179
|
-
locale: locale ?? sourceEntry.locale,
|
|
1180
|
-
// Don't copy primaryEntryId - this is a new independent entry
|
|
1181
|
-
version: 1,
|
|
1182
|
-
// Reset publishing timestamps - this is a new entry
|
|
1183
|
-
firstPublishedAt: undefined,
|
|
1184
|
-
lastPublishedAt: undefined,
|
|
1185
|
-
scheduledPublishAt: undefined,
|
|
1186
|
-
// Don't copy locks
|
|
1187
|
-
lockedBy: undefined,
|
|
1188
|
-
lockExpiresAt: undefined,
|
|
1189
|
-
// Set new audit trail
|
|
1190
|
-
createdBy,
|
|
1191
|
-
updatedBy: createdBy,
|
|
1192
|
-
searchText,
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
// Retrieve and return the created entry
|
|
1196
|
-
const entry = await ctx.db.get(entryId);
|
|
1197
|
-
if (!entry) {
|
|
1198
|
-
throw contentEntryCreateFailed(
|
|
1199
|
-
(sourceEntry.contentTypeId as unknown) as string,
|
|
1200
|
-
);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
// Emit content entry duplicated event
|
|
1204
|
-
await emitEvent(ctx, {
|
|
1205
|
-
eventType: contentEntryEventType("duplicated"),
|
|
1206
|
-
resourceType: "contentEntry",
|
|
1207
|
-
resourceId: (entryId as unknown) as string,
|
|
1208
|
-
action: "duplicated",
|
|
1209
|
-
payload: {
|
|
1210
|
-
slug: uniqueSlug,
|
|
1211
|
-
contentTypeName: contentType.name,
|
|
1212
|
-
contentTypeId: (sourceEntry.contentTypeId as unknown) as string,
|
|
1213
|
-
status: "draft",
|
|
1214
|
-
version: 1,
|
|
1215
|
-
locale: locale ?? sourceEntry.locale,
|
|
1216
|
-
sourceEntryId: (sourceEntryId as unknown) as string,
|
|
1217
|
-
} as ContentEntryEventPayload,
|
|
1218
|
-
userId: createdBy,
|
|
1219
|
-
});
|
|
1220
|
-
|
|
1221
|
-
return entry;
|
|
1222
|
-
},
|
|
1223
|
-
});
|