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.
Files changed (265) hide show
  1. package/admin-dist/nitro.json +15 -0
  2. package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
  3. package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
  4. package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
  5. package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
  6. package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
  7. package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
  8. package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
  9. package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
  10. package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
  11. package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
  12. package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
  13. package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
  14. package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
  15. package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
  16. package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
  17. package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
  18. package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
  19. package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
  20. package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
  21. package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
  22. package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
  23. package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
  24. package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
  25. package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
  26. package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
  27. package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
  28. package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
  29. package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
  30. package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
  31. package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
  32. package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
  33. package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
  34. package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
  35. package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
  36. package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
  37. package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
  38. package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
  39. package/admin-dist/public/favicon.ico +0 -0
  40. package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
  41. package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
  42. package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
  43. package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
  44. package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
  45. package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
  46. package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
  47. package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
  48. package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
  49. package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
  50. package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
  51. package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
  52. package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
  53. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
  54. package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
  55. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
  56. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
  57. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
  58. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
  59. package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
  60. package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
  61. package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
  62. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
  63. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
  64. package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
  65. package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
  66. package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
  67. package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
  68. package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
  69. package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
  70. package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
  71. package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
  72. package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
  73. package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
  74. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
  75. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
  76. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
  77. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
  78. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
  79. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
  80. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
  81. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
  82. package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
  83. package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
  84. package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
  85. package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
  86. package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
  87. package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
  88. package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
  89. package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
  90. package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
  91. package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
  92. package/admin-dist/server/_libs/clsx.mjs +16 -0
  93. package/admin-dist/server/_libs/cmdk.mjs +315 -0
  94. package/admin-dist/server/_libs/convex.mjs +4841 -0
  95. package/admin-dist/server/_libs/cookie-es.mjs +58 -0
  96. package/admin-dist/server/_libs/croner.mjs +1 -0
  97. package/admin-dist/server/_libs/crossws.mjs +1 -0
  98. package/admin-dist/server/_libs/date-fns.mjs +1716 -0
  99. package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
  100. package/admin-dist/server/_libs/get-nonce.mjs +9 -0
  101. package/admin-dist/server/_libs/h3-v2.mjs +277 -0
  102. package/admin-dist/server/_libs/h3.mjs +401 -0
  103. package/admin-dist/server/_libs/hookable.mjs +1 -0
  104. package/admin-dist/server/_libs/isbot.mjs +20 -0
  105. package/admin-dist/server/_libs/lucide-react.mjs +850 -0
  106. package/admin-dist/server/_libs/ohash.mjs +1 -0
  107. package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
  108. package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
  109. package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
  110. package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
  111. package/admin-dist/server/_libs/rou3.mjs +8 -0
  112. package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
  113. package/admin-dist/server/_libs/seroval.mjs +1765 -0
  114. package/admin-dist/server/_libs/srvx.mjs +719 -0
  115. package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
  116. package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
  117. package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
  118. package/admin-dist/server/_libs/tslib.mjs +39 -0
  119. package/admin-dist/server/_libs/ufo.mjs +54 -0
  120. package/admin-dist/server/_libs/unctx.mjs +1 -0
  121. package/admin-dist/server/_libs/unstorage.mjs +1 -0
  122. package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
  123. package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
  124. package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
  125. package/admin-dist/server/_libs/zod.mjs +4223 -0
  126. package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
  127. package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
  128. package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
  129. package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
  130. package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
  131. package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
  132. package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
  133. package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
  134. package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
  135. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
  136. package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
  137. package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
  138. package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
  139. package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
  140. package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
  141. package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
  142. package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
  143. package/admin-dist/server/_ssr/index.mjs +1275 -0
  144. package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
  145. package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
  146. package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
  147. package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
  148. package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
  149. package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
  150. package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
  151. package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
  152. package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
  153. package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
  154. package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
  155. package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
  156. package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
  157. package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
  158. package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
  159. package/admin-dist/server/favicon.ico +0 -0
  160. package/admin-dist/server/index.mjs +627 -0
  161. package/dist/cli/index.js +0 -0
  162. package/dist/client/admin-config.d.ts +0 -1
  163. package/dist/client/admin-config.d.ts.map +1 -1
  164. package/dist/client/admin-config.js +0 -1
  165. package/dist/client/admin-config.js.map +1 -1
  166. package/dist/client/adminApi.d.ts.map +1 -1
  167. package/dist/client/agentTools.d.ts +1237 -135
  168. package/dist/client/agentTools.d.ts.map +1 -1
  169. package/dist/client/agentTools.js +33 -9
  170. package/dist/client/agentTools.js.map +1 -1
  171. package/dist/client/index.d.ts +1 -1
  172. package/dist/client/index.d.ts.map +1 -1
  173. package/dist/client/index.js.map +1 -1
  174. package/dist/component/_generated/component.d.ts +9 -0
  175. package/dist/component/_generated/component.d.ts.map +1 -1
  176. package/dist/component/mediaAssets.d.ts +35 -0
  177. package/dist/component/mediaAssets.d.ts.map +1 -1
  178. package/dist/component/mediaAssets.js +81 -0
  179. package/dist/component/mediaAssets.js.map +1 -1
  180. package/dist/test.d.ts.map +1 -1
  181. package/dist/test.js +2 -1
  182. package/dist/test.js.map +1 -1
  183. package/package.json +9 -5
  184. package/dist/component/auditLog.d.ts +0 -410
  185. package/dist/component/auditLog.d.ts.map +0 -1
  186. package/dist/component/auditLog.js +0 -607
  187. package/dist/component/auditLog.js.map +0 -1
  188. package/dist/component/types.d.ts +0 -4
  189. package/dist/component/types.d.ts.map +0 -1
  190. package/dist/component/types.js +0 -2
  191. package/dist/component/types.js.map +0 -1
  192. package/src/cli/commands/admin.ts +0 -104
  193. package/src/cli/index.ts +0 -21
  194. package/src/cli/utils/detectConvexUrl.ts +0 -54
  195. package/src/cli/utils/openBrowser.ts +0 -16
  196. package/src/client/admin-config.ts +0 -138
  197. package/src/client/adminApi.ts +0 -942
  198. package/src/client/agentTools.ts +0 -1311
  199. package/src/client/argTypes.ts +0 -316
  200. package/src/client/field-types.ts +0 -187
  201. package/src/client/index.ts +0 -1301
  202. package/src/client/queryBuilder.ts +0 -1100
  203. package/src/client/schema/codegen.ts +0 -500
  204. package/src/client/schema/defineContentType.ts +0 -501
  205. package/src/client/schema/index.ts +0 -169
  206. package/src/client/schema/schemaDrift.ts +0 -574
  207. package/src/client/schema/typedClient.ts +0 -688
  208. package/src/client/schema/types.ts +0 -666
  209. package/src/client/types.ts +0 -723
  210. package/src/client/workflows.ts +0 -141
  211. package/src/client/wrapper.ts +0 -4304
  212. package/src/component/_generated/api.ts +0 -140
  213. package/src/component/_generated/component.ts +0 -5029
  214. package/src/component/_generated/dataModel.ts +0 -60
  215. package/src/component/_generated/server.ts +0 -156
  216. package/src/component/authorization.ts +0 -647
  217. package/src/component/authorizationHooks.ts +0 -668
  218. package/src/component/bulkOperations.ts +0 -687
  219. package/src/component/contentEntries.ts +0 -1976
  220. package/src/component/contentEntryMutations.ts +0 -1223
  221. package/src/component/contentEntryValidation.ts +0 -707
  222. package/src/component/contentLock.ts +0 -550
  223. package/src/component/contentTypeMigration.ts +0 -1064
  224. package/src/component/contentTypeMutations.ts +0 -969
  225. package/src/component/contentTypes.ts +0 -346
  226. package/src/component/convex.config.ts +0 -44
  227. package/src/component/documentTypes.ts +0 -240
  228. package/src/component/eventEmitter.ts +0 -485
  229. package/src/component/exportImport.ts +0 -1169
  230. package/src/component/index.ts +0 -491
  231. package/src/component/lib/deepReferenceResolver.ts +0 -999
  232. package/src/component/lib/errors.ts +0 -816
  233. package/src/component/lib/index.ts +0 -145
  234. package/src/component/lib/mediaReferenceResolver.ts +0 -495
  235. package/src/component/lib/metadataExtractor.ts +0 -792
  236. package/src/component/lib/mutationAuth.ts +0 -199
  237. package/src/component/lib/queries.ts +0 -79
  238. package/src/component/lib/ragContentChunker.ts +0 -1371
  239. package/src/component/lib/referenceResolver.ts +0 -430
  240. package/src/component/lib/slugGenerator.ts +0 -262
  241. package/src/component/lib/slugUniqueness.ts +0 -333
  242. package/src/component/lib/softDelete.ts +0 -44
  243. package/src/component/localeFallbackChain.ts +0 -673
  244. package/src/component/localeFields.ts +0 -896
  245. package/src/component/mediaAssetMutations.ts +0 -725
  246. package/src/component/mediaAssets.ts +0 -932
  247. package/src/component/mediaFolderMutations.ts +0 -1046
  248. package/src/component/mediaUploadMutations.ts +0 -224
  249. package/src/component/mediaVariantMutations.ts +0 -900
  250. package/src/component/mediaVariants.ts +0 -793
  251. package/src/component/ragContentIndexer.ts +0 -1067
  252. package/src/component/rateLimitHooks.ts +0 -572
  253. package/src/component/roles.ts +0 -1360
  254. package/src/component/scheduledPublish.ts +0 -358
  255. package/src/component/schema.ts +0 -617
  256. package/src/component/taxonomies.ts +0 -949
  257. package/src/component/taxonomyMutations.ts +0 -1210
  258. package/src/component/trash.ts +0 -724
  259. package/src/component/userContext.ts +0 -898
  260. package/src/component/validation.ts +0 -1388
  261. package/src/component/validators.ts +0 -949
  262. package/src/component/versionMutations.ts +0 -392
  263. package/src/component/webhookTrigger.ts +0 -1922
  264. package/src/react/index.ts +0 -898
  265. 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
- });