convex-cms 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/dist/cli/commands/admin.d.ts +16 -0
  2. package/dist/cli/commands/admin.d.ts.map +1 -0
  3. package/dist/cli/commands/admin.js +88 -0
  4. package/dist/cli/commands/admin.js.map +1 -0
  5. package/dist/cli/index.d.ts +3 -0
  6. package/dist/cli/index.d.ts.map +1 -0
  7. package/dist/cli/index.js +18 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/cli/utils/detectConvexUrl.d.ts +13 -0
  10. package/dist/cli/utils/detectConvexUrl.d.ts.map +1 -0
  11. package/dist/cli/utils/detectConvexUrl.js +48 -0
  12. package/dist/cli/utils/detectConvexUrl.js.map +1 -0
  13. package/dist/cli/utils/openBrowser.d.ts +7 -0
  14. package/dist/cli/utils/openBrowser.d.ts.map +1 -0
  15. package/dist/cli/utils/openBrowser.js +17 -0
  16. package/dist/cli/utils/openBrowser.js.map +1 -0
  17. package/dist/client/admin-config.d.ts +126 -0
  18. package/dist/client/admin-config.d.ts.map +1 -0
  19. package/dist/client/admin-config.js +117 -0
  20. package/dist/client/admin-config.js.map +1 -0
  21. package/dist/client/adminApi.d.ts +2273 -0
  22. package/dist/client/adminApi.d.ts.map +1 -0
  23. package/dist/client/adminApi.js +716 -0
  24. package/dist/client/adminApi.js.map +1 -0
  25. package/dist/client/agentTools.d.ts +933 -0
  26. package/dist/client/agentTools.d.ts.map +1 -0
  27. package/dist/client/agentTools.js +1004 -0
  28. package/dist/client/agentTools.js.map +1 -0
  29. package/dist/client/argTypes.d.ts +212 -0
  30. package/dist/client/argTypes.d.ts.map +1 -0
  31. package/dist/client/argTypes.js +5 -0
  32. package/dist/client/argTypes.js.map +1 -0
  33. package/dist/client/field-types.d.ts +55 -0
  34. package/dist/client/field-types.d.ts.map +1 -0
  35. package/dist/client/field-types.js +152 -0
  36. package/dist/client/field-types.js.map +1 -0
  37. package/dist/client/index.d.ts +189 -0
  38. package/dist/client/index.d.ts.map +1 -0
  39. package/dist/client/index.js +668 -0
  40. package/dist/client/index.js.map +1 -0
  41. package/dist/client/queryBuilder.d.ts +765 -0
  42. package/dist/client/queryBuilder.d.ts.map +1 -0
  43. package/dist/client/queryBuilder.js +970 -0
  44. package/dist/client/queryBuilder.js.map +1 -0
  45. package/dist/client/schema/codegen.d.ts +128 -0
  46. package/dist/client/schema/codegen.d.ts.map +1 -0
  47. package/dist/client/schema/codegen.js +318 -0
  48. package/dist/client/schema/codegen.js.map +1 -0
  49. package/dist/client/schema/defineContentType.d.ts +221 -0
  50. package/dist/client/schema/defineContentType.d.ts.map +1 -0
  51. package/dist/client/schema/defineContentType.js +380 -0
  52. package/dist/client/schema/defineContentType.js.map +1 -0
  53. package/dist/client/schema/index.d.ts +85 -0
  54. package/dist/client/schema/index.d.ts.map +1 -0
  55. package/dist/client/schema/index.js +92 -0
  56. package/dist/client/schema/index.js.map +1 -0
  57. package/dist/client/schema/schemaDrift.d.ts +199 -0
  58. package/dist/client/schema/schemaDrift.d.ts.map +1 -0
  59. package/dist/client/schema/schemaDrift.js +340 -0
  60. package/dist/client/schema/schemaDrift.js.map +1 -0
  61. package/dist/client/schema/typedClient.d.ts +401 -0
  62. package/dist/client/schema/typedClient.d.ts.map +1 -0
  63. package/dist/client/schema/typedClient.js +269 -0
  64. package/dist/client/schema/typedClient.js.map +1 -0
  65. package/dist/client/schema/types.d.ts +477 -0
  66. package/dist/client/schema/types.d.ts.map +1 -0
  67. package/dist/client/schema/types.js +39 -0
  68. package/dist/client/schema/types.js.map +1 -0
  69. package/dist/client/types.d.ts +449 -0
  70. package/dist/client/types.d.ts.map +1 -0
  71. package/dist/client/types.js +149 -0
  72. package/dist/client/types.js.map +1 -0
  73. package/dist/client/workflows.d.ts +51 -0
  74. package/dist/client/workflows.d.ts.map +1 -0
  75. package/dist/client/workflows.js +103 -0
  76. package/dist/client/workflows.js.map +1 -0
  77. package/dist/client/wrapper.d.ts +2198 -0
  78. package/dist/client/wrapper.d.ts.map +1 -0
  79. package/dist/client/wrapper.js +2651 -0
  80. package/dist/client/wrapper.js.map +1 -0
  81. package/dist/component/_generated/api.d.ts +124 -0
  82. package/dist/component/_generated/api.d.ts.map +1 -0
  83. package/dist/component/_generated/api.js +31 -0
  84. package/dist/component/_generated/api.js.map +1 -0
  85. package/dist/component/_generated/component.d.ts +4321 -0
  86. package/dist/component/_generated/component.d.ts.map +1 -0
  87. package/dist/component/_generated/component.js +11 -0
  88. package/dist/component/_generated/component.js.map +1 -0
  89. package/dist/component/_generated/dataModel.d.ts +46 -0
  90. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  91. package/dist/component/_generated/dataModel.js +11 -0
  92. package/dist/component/_generated/dataModel.js.map +1 -0
  93. package/dist/component/_generated/server.d.ts +121 -0
  94. package/dist/component/_generated/server.d.ts.map +1 -0
  95. package/dist/component/_generated/server.js +78 -0
  96. package/dist/component/_generated/server.js.map +1 -0
  97. package/dist/component/auditLog.d.ts +410 -0
  98. package/dist/component/auditLog.d.ts.map +1 -0
  99. package/dist/component/auditLog.js +607 -0
  100. package/dist/component/auditLog.js.map +1 -0
  101. package/dist/component/authorization.d.ts +323 -0
  102. package/dist/component/authorization.d.ts.map +1 -0
  103. package/dist/component/authorization.js +464 -0
  104. package/dist/component/authorization.js.map +1 -0
  105. package/dist/component/authorizationHooks.d.ts +184 -0
  106. package/dist/component/authorizationHooks.d.ts.map +1 -0
  107. package/dist/component/authorizationHooks.js +521 -0
  108. package/dist/component/authorizationHooks.js.map +1 -0
  109. package/dist/component/bulkOperations.d.ts +200 -0
  110. package/dist/component/bulkOperations.d.ts.map +1 -0
  111. package/dist/component/bulkOperations.js +568 -0
  112. package/dist/component/bulkOperations.js.map +1 -0
  113. package/dist/component/contentEntries.d.ts +719 -0
  114. package/dist/component/contentEntries.d.ts.map +1 -0
  115. package/dist/component/contentEntries.js +1617 -0
  116. package/dist/component/contentEntries.js.map +1 -0
  117. package/dist/component/contentEntryMutations.d.ts +505 -0
  118. package/dist/component/contentEntryMutations.d.ts.map +1 -0
  119. package/dist/component/contentEntryMutations.js +1009 -0
  120. package/dist/component/contentEntryMutations.js.map +1 -0
  121. package/dist/component/contentEntryValidation.d.ts +115 -0
  122. package/dist/component/contentEntryValidation.d.ts.map +1 -0
  123. package/dist/component/contentEntryValidation.js +546 -0
  124. package/dist/component/contentEntryValidation.js.map +1 -0
  125. package/dist/component/contentLock.d.ts +328 -0
  126. package/dist/component/contentLock.d.ts.map +1 -0
  127. package/dist/component/contentLock.js +471 -0
  128. package/dist/component/contentLock.js.map +1 -0
  129. package/dist/component/contentTypeMigration.d.ts +411 -0
  130. package/dist/component/contentTypeMigration.d.ts.map +1 -0
  131. package/dist/component/contentTypeMigration.js +805 -0
  132. package/dist/component/contentTypeMigration.js.map +1 -0
  133. package/dist/component/contentTypeMutations.d.ts +975 -0
  134. package/dist/component/contentTypeMutations.d.ts.map +1 -0
  135. package/dist/component/contentTypeMutations.js +768 -0
  136. package/dist/component/contentTypeMutations.js.map +1 -0
  137. package/dist/component/contentTypes.d.ts +538 -0
  138. package/dist/component/contentTypes.d.ts.map +1 -0
  139. package/dist/component/contentTypes.js +304 -0
  140. package/dist/component/contentTypes.js.map +1 -0
  141. package/dist/component/convex.config.d.ts +42 -0
  142. package/dist/component/convex.config.d.ts.map +1 -0
  143. package/dist/component/convex.config.js +43 -0
  144. package/dist/component/convex.config.js.map +1 -0
  145. package/dist/component/documentTypes.d.ts +186 -0
  146. package/dist/component/documentTypes.d.ts.map +1 -0
  147. package/dist/component/documentTypes.js +23 -0
  148. package/dist/component/documentTypes.js.map +1 -0
  149. package/dist/component/eventEmitter.d.ts +281 -0
  150. package/dist/component/eventEmitter.d.ts.map +1 -0
  151. package/dist/component/eventEmitter.js +300 -0
  152. package/dist/component/eventEmitter.js.map +1 -0
  153. package/dist/component/exportImport.d.ts +1120 -0
  154. package/dist/component/exportImport.d.ts.map +1 -0
  155. package/dist/component/exportImport.js +931 -0
  156. package/dist/component/exportImport.js.map +1 -0
  157. package/dist/component/index.d.ts +28 -0
  158. package/dist/component/index.d.ts.map +1 -0
  159. package/dist/component/index.js +142 -0
  160. package/dist/component/index.js.map +1 -0
  161. package/dist/component/lib/deepReferenceResolver.d.ts +252 -0
  162. package/dist/component/lib/deepReferenceResolver.d.ts.map +1 -0
  163. package/dist/component/lib/deepReferenceResolver.js +601 -0
  164. package/dist/component/lib/deepReferenceResolver.js.map +1 -0
  165. package/dist/component/lib/errors.d.ts +306 -0
  166. package/dist/component/lib/errors.d.ts.map +1 -0
  167. package/dist/component/lib/errors.js +407 -0
  168. package/dist/component/lib/errors.js.map +1 -0
  169. package/dist/component/lib/index.d.ts +10 -0
  170. package/dist/component/lib/index.d.ts.map +1 -0
  171. package/dist/component/lib/index.js +33 -0
  172. package/dist/component/lib/index.js.map +1 -0
  173. package/dist/component/lib/mediaReferenceResolver.d.ts +217 -0
  174. package/dist/component/lib/mediaReferenceResolver.d.ts.map +1 -0
  175. package/dist/component/lib/mediaReferenceResolver.js +326 -0
  176. package/dist/component/lib/mediaReferenceResolver.js.map +1 -0
  177. package/dist/component/lib/metadataExtractor.d.ts +245 -0
  178. package/dist/component/lib/metadataExtractor.d.ts.map +1 -0
  179. package/dist/component/lib/metadataExtractor.js +548 -0
  180. package/dist/component/lib/metadataExtractor.js.map +1 -0
  181. package/dist/component/lib/mutationAuth.d.ts +95 -0
  182. package/dist/component/lib/mutationAuth.d.ts.map +1 -0
  183. package/dist/component/lib/mutationAuth.js +146 -0
  184. package/dist/component/lib/mutationAuth.js.map +1 -0
  185. package/dist/component/lib/queries.d.ts +17 -0
  186. package/dist/component/lib/queries.d.ts.map +1 -0
  187. package/dist/component/lib/queries.js +49 -0
  188. package/dist/component/lib/queries.js.map +1 -0
  189. package/dist/component/lib/ragContentChunker.d.ts +423 -0
  190. package/dist/component/lib/ragContentChunker.d.ts.map +1 -0
  191. package/dist/component/lib/ragContentChunker.js +897 -0
  192. package/dist/component/lib/ragContentChunker.js.map +1 -0
  193. package/dist/component/lib/referenceResolver.d.ts +175 -0
  194. package/dist/component/lib/referenceResolver.d.ts.map +1 -0
  195. package/dist/component/lib/referenceResolver.js +293 -0
  196. package/dist/component/lib/referenceResolver.js.map +1 -0
  197. package/dist/component/lib/slugGenerator.d.ts +71 -0
  198. package/dist/component/lib/slugGenerator.d.ts.map +1 -0
  199. package/dist/component/lib/slugGenerator.js +207 -0
  200. package/dist/component/lib/slugGenerator.js.map +1 -0
  201. package/dist/component/lib/slugUniqueness.d.ts +131 -0
  202. package/dist/component/lib/slugUniqueness.d.ts.map +1 -0
  203. package/dist/component/lib/slugUniqueness.js +229 -0
  204. package/dist/component/lib/slugUniqueness.js.map +1 -0
  205. package/dist/component/lib/softDelete.d.ts +18 -0
  206. package/dist/component/lib/softDelete.d.ts.map +1 -0
  207. package/dist/component/lib/softDelete.js +29 -0
  208. package/dist/component/lib/softDelete.js.map +1 -0
  209. package/dist/component/localeFallbackChain.d.ts +410 -0
  210. package/dist/component/localeFallbackChain.d.ts.map +1 -0
  211. package/dist/component/localeFallbackChain.js +467 -0
  212. package/dist/component/localeFallbackChain.js.map +1 -0
  213. package/dist/component/localeFields.d.ts +508 -0
  214. package/dist/component/localeFields.d.ts.map +1 -0
  215. package/dist/component/localeFields.js +592 -0
  216. package/dist/component/localeFields.js.map +1 -0
  217. package/dist/component/mediaAssetMutations.d.ts +235 -0
  218. package/dist/component/mediaAssetMutations.d.ts.map +1 -0
  219. package/dist/component/mediaAssetMutations.js +558 -0
  220. package/dist/component/mediaAssetMutations.js.map +1 -0
  221. package/dist/component/mediaAssets.d.ts +168 -0
  222. package/dist/component/mediaAssets.d.ts.map +1 -0
  223. package/dist/component/mediaAssets.js +618 -0
  224. package/dist/component/mediaAssets.js.map +1 -0
  225. package/dist/component/mediaFolderMutations.d.ts +642 -0
  226. package/dist/component/mediaFolderMutations.d.ts.map +1 -0
  227. package/dist/component/mediaFolderMutations.js +849 -0
  228. package/dist/component/mediaFolderMutations.js.map +1 -0
  229. package/dist/component/mediaUploadMutations.d.ts +136 -0
  230. package/dist/component/mediaUploadMutations.d.ts.map +1 -0
  231. package/dist/component/mediaUploadMutations.js +205 -0
  232. package/dist/component/mediaUploadMutations.js.map +1 -0
  233. package/dist/component/mediaVariantMutations.d.ts +468 -0
  234. package/dist/component/mediaVariantMutations.d.ts.map +1 -0
  235. package/dist/component/mediaVariantMutations.js +737 -0
  236. package/dist/component/mediaVariantMutations.js.map +1 -0
  237. package/dist/component/mediaVariants.d.ts +525 -0
  238. package/dist/component/mediaVariants.d.ts.map +1 -0
  239. package/dist/component/mediaVariants.js +661 -0
  240. package/dist/component/mediaVariants.js.map +1 -0
  241. package/dist/component/ragContentIndexer.d.ts +595 -0
  242. package/dist/component/ragContentIndexer.d.ts.map +1 -0
  243. package/dist/component/ragContentIndexer.js +794 -0
  244. package/dist/component/ragContentIndexer.js.map +1 -0
  245. package/dist/component/rateLimitHooks.d.ts +266 -0
  246. package/dist/component/rateLimitHooks.d.ts.map +1 -0
  247. package/dist/component/rateLimitHooks.js +412 -0
  248. package/dist/component/rateLimitHooks.js.map +1 -0
  249. package/dist/component/roles.d.ts +649 -0
  250. package/dist/component/roles.d.ts.map +1 -0
  251. package/dist/component/roles.js +884 -0
  252. package/dist/component/roles.js.map +1 -0
  253. package/dist/component/scheduledPublish.d.ts +182 -0
  254. package/dist/component/scheduledPublish.d.ts.map +1 -0
  255. package/dist/component/scheduledPublish.js +304 -0
  256. package/dist/component/scheduledPublish.js.map +1 -0
  257. package/dist/component/schema.d.ts +4114 -0
  258. package/dist/component/schema.d.ts.map +1 -0
  259. package/dist/component/schema.js +469 -0
  260. package/dist/component/schema.js.map +1 -0
  261. package/dist/component/taxonomies.d.ts +476 -0
  262. package/dist/component/taxonomies.d.ts.map +1 -0
  263. package/dist/component/taxonomies.js +785 -0
  264. package/dist/component/taxonomies.js.map +1 -0
  265. package/dist/component/taxonomyMutations.d.ts +206 -0
  266. package/dist/component/taxonomyMutations.d.ts.map +1 -0
  267. package/dist/component/taxonomyMutations.js +1001 -0
  268. package/dist/component/taxonomyMutations.js.map +1 -0
  269. package/dist/component/trash.d.ts +265 -0
  270. package/dist/component/trash.d.ts.map +1 -0
  271. package/dist/component/trash.js +621 -0
  272. package/dist/component/trash.js.map +1 -0
  273. package/dist/component/types.d.ts +4 -0
  274. package/dist/component/types.d.ts.map +1 -0
  275. package/dist/component/types.js +2 -0
  276. package/dist/component/types.js.map +1 -0
  277. package/dist/component/userContext.d.ts +508 -0
  278. package/dist/component/userContext.d.ts.map +1 -0
  279. package/dist/component/userContext.js +615 -0
  280. package/dist/component/userContext.js.map +1 -0
  281. package/dist/component/validation.d.ts +387 -0
  282. package/dist/component/validation.d.ts.map +1 -0
  283. package/dist/component/validation.js +1052 -0
  284. package/dist/component/validation.js.map +1 -0
  285. package/dist/component/validators.d.ts +4645 -0
  286. package/dist/component/validators.d.ts.map +1 -0
  287. package/dist/component/validators.js +641 -0
  288. package/dist/component/validators.js.map +1 -0
  289. package/dist/component/versionMutations.d.ts +216 -0
  290. package/dist/component/versionMutations.d.ts.map +1 -0
  291. package/dist/component/versionMutations.js +321 -0
  292. package/dist/component/versionMutations.js.map +1 -0
  293. package/dist/component/webhookTrigger.d.ts +770 -0
  294. package/dist/component/webhookTrigger.d.ts.map +1 -0
  295. package/dist/component/webhookTrigger.js +1413 -0
  296. package/dist/component/webhookTrigger.js.map +1 -0
  297. package/dist/react/index.d.ts +316 -0
  298. package/dist/react/index.d.ts.map +1 -0
  299. package/dist/react/index.js +558 -0
  300. package/dist/react/index.js.map +1 -0
  301. package/dist/test.d.ts +2230 -0
  302. package/dist/test.d.ts.map +1 -0
  303. package/dist/test.js +1107 -0
  304. package/dist/test.js.map +1 -0
  305. package/package.json +95 -0
  306. package/src/cli/commands/admin.ts +104 -0
  307. package/src/cli/index.ts +21 -0
  308. package/src/cli/utils/detectConvexUrl.ts +54 -0
  309. package/src/cli/utils/openBrowser.ts +16 -0
  310. package/src/client/admin-config.ts +138 -0
  311. package/src/client/adminApi.ts +942 -0
  312. package/src/client/agentTools.ts +1311 -0
  313. package/src/client/argTypes.ts +316 -0
  314. package/src/client/field-types.ts +187 -0
  315. package/src/client/index.ts +1301 -0
  316. package/src/client/queryBuilder.ts +1100 -0
  317. package/src/client/schema/codegen.ts +500 -0
  318. package/src/client/schema/defineContentType.ts +501 -0
  319. package/src/client/schema/index.ts +169 -0
  320. package/src/client/schema/schemaDrift.ts +574 -0
  321. package/src/client/schema/typedClient.ts +688 -0
  322. package/src/client/schema/types.ts +666 -0
  323. package/src/client/types.ts +723 -0
  324. package/src/client/workflows.ts +141 -0
  325. package/src/client/wrapper.ts +4304 -0
  326. package/src/component/_generated/api.ts +140 -0
  327. package/src/component/_generated/component.ts +5029 -0
  328. package/src/component/_generated/dataModel.ts +60 -0
  329. package/src/component/_generated/server.ts +156 -0
  330. package/src/component/authorization.ts +647 -0
  331. package/src/component/authorizationHooks.ts +668 -0
  332. package/src/component/bulkOperations.ts +687 -0
  333. package/src/component/contentEntries.ts +1976 -0
  334. package/src/component/contentEntryMutations.ts +1223 -0
  335. package/src/component/contentEntryValidation.ts +707 -0
  336. package/src/component/contentLock.ts +550 -0
  337. package/src/component/contentTypeMigration.ts +1064 -0
  338. package/src/component/contentTypeMutations.ts +969 -0
  339. package/src/component/contentTypes.ts +346 -0
  340. package/src/component/convex.config.ts +44 -0
  341. package/src/component/documentTypes.ts +240 -0
  342. package/src/component/eventEmitter.ts +485 -0
  343. package/src/component/exportImport.ts +1169 -0
  344. package/src/component/index.ts +491 -0
  345. package/src/component/lib/deepReferenceResolver.ts +999 -0
  346. package/src/component/lib/errors.ts +816 -0
  347. package/src/component/lib/index.ts +145 -0
  348. package/src/component/lib/mediaReferenceResolver.ts +495 -0
  349. package/src/component/lib/metadataExtractor.ts +792 -0
  350. package/src/component/lib/mutationAuth.ts +199 -0
  351. package/src/component/lib/queries.ts +79 -0
  352. package/src/component/lib/ragContentChunker.ts +1371 -0
  353. package/src/component/lib/referenceResolver.ts +430 -0
  354. package/src/component/lib/slugGenerator.ts +262 -0
  355. package/src/component/lib/slugUniqueness.ts +333 -0
  356. package/src/component/lib/softDelete.ts +44 -0
  357. package/src/component/localeFallbackChain.ts +673 -0
  358. package/src/component/localeFields.ts +896 -0
  359. package/src/component/mediaAssetMutations.ts +725 -0
  360. package/src/component/mediaAssets.ts +932 -0
  361. package/src/component/mediaFolderMutations.ts +1046 -0
  362. package/src/component/mediaUploadMutations.ts +224 -0
  363. package/src/component/mediaVariantMutations.ts +900 -0
  364. package/src/component/mediaVariants.ts +793 -0
  365. package/src/component/ragContentIndexer.ts +1067 -0
  366. package/src/component/rateLimitHooks.ts +572 -0
  367. package/src/component/roles.ts +1360 -0
  368. package/src/component/scheduledPublish.ts +358 -0
  369. package/src/component/schema.ts +617 -0
  370. package/src/component/taxonomies.ts +949 -0
  371. package/src/component/taxonomyMutations.ts +1210 -0
  372. package/src/component/trash.ts +724 -0
  373. package/src/component/userContext.ts +898 -0
  374. package/src/component/validation.ts +1388 -0
  375. package/src/component/validators.ts +949 -0
  376. package/src/component/versionMutations.ts +392 -0
  377. package/src/component/webhookTrigger.ts +1922 -0
  378. package/src/react/index.ts +898 -0
  379. package/src/test.ts +1580 -0
@@ -0,0 +1,1210 @@
1
+ /**
2
+ * Taxonomy Mutation Functions
3
+ *
4
+ * Provides mutation functions for managing taxonomies and terms.
5
+ *
6
+ * Available mutations:
7
+ * - Taxonomies: create, update, delete (soft), restore
8
+ * - Terms: create, update, delete (soft), restore, reorder
9
+ * - Entry Tags: setEntryTerms, addTermToEntry, removeTermFromEntry
10
+ */
11
+
12
+ import { v } from "convex/values";
13
+ import { isDeleted } from "./lib/softDelete.js";
14
+ import { mutation, MutationCtx } from "./_generated/server.js";
15
+ import type { Id } from "./_generated/dataModel.js";
16
+ import { generateSlug } from "./lib/slugGenerator.js";
17
+
18
+ // =============================================================================
19
+ // Helper Functions
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Build the full path for a term in a hierarchy.
24
+ */
25
+ async function buildTermPath(
26
+ ctx: MutationCtx,
27
+ parentId: Id<"taxonomyTerms"> | undefined,
28
+ slug: string,
29
+ ): Promise<string> {
30
+ if (!parentId) {
31
+ return `/${slug}`;
32
+ }
33
+
34
+ const parent = await ctx.db.get(parentId);
35
+ if (!parent || isDeleted(parent)) {
36
+ return `/${slug}`;
37
+ }
38
+
39
+ const parentPath = parent.path ?? `/${parent.slug}`;
40
+ return `${parentPath}/${slug}`;
41
+ }
42
+
43
+ /**
44
+ * Calculate depth based on parent.
45
+ */
46
+ async function calculateDepth(
47
+ ctx: MutationCtx,
48
+ parentId: Id<"taxonomyTerms"> | undefined,
49
+ ): Promise<number> {
50
+ if (!parentId) {
51
+ return 0;
52
+ }
53
+
54
+ const parent = await ctx.db.get(parentId);
55
+ if (!parent || isDeleted(parent)) {
56
+ return 0;
57
+ }
58
+
59
+ return parent.depth + 1;
60
+ }
61
+
62
+ /**
63
+ * Update all descendant paths when a term is moved.
64
+ */
65
+ async function updateDescendantPaths(
66
+ ctx: MutationCtx,
67
+ termId: Id<"taxonomyTerms">,
68
+ oldPath: string,
69
+ newPath: string,
70
+ ): Promise<void> {
71
+ // Get all terms that start with the old path
72
+ const descendants = await ctx.db
73
+ .query("taxonomyTerms")
74
+ .filter((q) => q.gte(q.field("path"), oldPath))
75
+ .collect();
76
+
77
+ for (const desc of descendants) {
78
+ if (desc._id !== termId && desc.path?.startsWith(oldPath + "/")) {
79
+ const updatedPath = desc.path.replace(oldPath, newPath);
80
+ const updatedDepth = updatedPath.split("/").filter((p) => p).length - 1;
81
+ await ctx.db.patch(desc._id, {
82
+ path: updatedPath,
83
+ depth: updatedDepth,
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ // =============================================================================
90
+ // Taxonomy Mutations
91
+ // =============================================================================
92
+
93
+ /**
94
+ * Create a new taxonomy.
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * const taxonomyId = await ctx.runMutation(api.taxonomyMutations.createTaxonomy, {
99
+ * name: "tags",
100
+ * displayName: "Tags",
101
+ * isHierarchical: false,
102
+ * allowInlineCreation: true,
103
+ * });
104
+ * ```
105
+ */
106
+ export const createTaxonomy = mutation({
107
+ args: {
108
+ name: v.string(),
109
+ displayName: v.string(),
110
+ description: v.optional(v.string()),
111
+ isHierarchical: v.boolean(),
112
+ allowInlineCreation: v.boolean(),
113
+ icon: v.optional(v.string()),
114
+ sortOrder: v.optional(v.number()),
115
+ userId: v.optional(v.string()),
116
+ },
117
+ returns: v.id("taxonomies"),
118
+ handler: async (ctx, args) => {
119
+ const {
120
+ name,
121
+ displayName,
122
+ description,
123
+ isHierarchical,
124
+ allowInlineCreation,
125
+ icon,
126
+ sortOrder,
127
+ userId,
128
+ } = args;
129
+
130
+ // Check for duplicate name
131
+ const existing = await ctx.db
132
+ .query("taxonomies")
133
+ .withIndex("by_name", (q) => q.eq("name", name))
134
+ .first();
135
+
136
+ if (existing && !isDeleted(existing)) {
137
+ throw new Error(`Taxonomy with name "${name}" already exists`);
138
+ }
139
+
140
+ // If there was a soft-deleted taxonomy with this name, restore and update it
141
+ if (existing) {
142
+ await ctx.db.patch(existing._id, {
143
+ displayName,
144
+ description,
145
+ isHierarchical,
146
+ allowInlineCreation,
147
+ icon,
148
+ sortOrder,
149
+ isActive: true,
150
+ deletedAt: undefined,
151
+ updatedBy: userId,
152
+ });
153
+ return existing._id;
154
+ }
155
+
156
+ const taxonomyId = await ctx.db.insert("taxonomies", {
157
+ name,
158
+ displayName,
159
+ description,
160
+ isHierarchical,
161
+ allowInlineCreation,
162
+ icon,
163
+ sortOrder,
164
+ isActive: true,
165
+ createdBy: userId,
166
+ });
167
+
168
+ return taxonomyId;
169
+ },
170
+ });
171
+
172
+ /**
173
+ * Update an existing taxonomy.
174
+ */
175
+ export const updateTaxonomy = mutation({
176
+ args: {
177
+ id: v.id("taxonomies"),
178
+ displayName: v.optional(v.string()),
179
+ description: v.optional(v.string()),
180
+ allowInlineCreation: v.optional(v.boolean()),
181
+ icon: v.optional(v.string()),
182
+ sortOrder: v.optional(v.number()),
183
+ isActive: v.optional(v.boolean()),
184
+ userId: v.optional(v.string()),
185
+ },
186
+ returns: v.id("taxonomies"),
187
+ handler: async (ctx, args) => {
188
+ const { id, userId, ...updates } = args;
189
+
190
+ const taxonomy = await ctx.db.get(id);
191
+ if (!taxonomy) {
192
+ throw new Error("Taxonomy not found");
193
+ }
194
+
195
+ if (isDeleted(taxonomy)) {
196
+ throw new Error("Cannot update deleted taxonomy");
197
+ }
198
+
199
+ // Build update object
200
+ const updateFields: any = { updatedBy: userId };
201
+ if (updates.displayName !== undefined)
202
+ updateFields.displayName = updates.displayName;
203
+ if (updates.description !== undefined)
204
+ updateFields.description = updates.description;
205
+ if (updates.allowInlineCreation !== undefined)
206
+ updateFields.allowInlineCreation = updates.allowInlineCreation;
207
+ if (updates.icon !== undefined) updateFields.icon = updates.icon;
208
+ if (updates.sortOrder !== undefined)
209
+ updateFields.sortOrder = updates.sortOrder;
210
+ if (updates.isActive !== undefined)
211
+ updateFields.isActive = updates.isActive;
212
+
213
+ await ctx.db.patch(id, updateFields);
214
+
215
+ return id;
216
+ },
217
+ });
218
+
219
+ /**
220
+ * Soft delete a taxonomy.
221
+ */
222
+ export const deleteTaxonomy = mutation({
223
+ args: {
224
+ id: v.id("taxonomies"),
225
+ userId: v.optional(v.string()),
226
+ },
227
+ returns: v.null(),
228
+ handler: async (ctx, args) => {
229
+ const { id, userId } = args;
230
+
231
+ const taxonomy = await ctx.db.get(id);
232
+ if (!taxonomy) {
233
+ throw new Error("Taxonomy not found");
234
+ }
235
+
236
+ if (isDeleted(taxonomy)) {
237
+ return null; // Already deleted
238
+ }
239
+
240
+ // Soft delete the taxonomy
241
+ await ctx.db.patch(id, {
242
+ deletedAt: Date.now(),
243
+ isActive: false,
244
+ updatedBy: userId,
245
+ });
246
+
247
+ // Also soft delete all terms in this taxonomy
248
+ const terms = await ctx.db
249
+ .query("taxonomyTerms")
250
+ .withIndex("by_taxonomy", (q) => q.eq("taxonomyId", id))
251
+ .collect();
252
+
253
+ for (const term of terms) {
254
+ if (!isDeleted(term)) {
255
+ await ctx.db.patch(term._id, {
256
+ deletedAt: Date.now(),
257
+ updatedBy: userId,
258
+ });
259
+ }
260
+ }
261
+
262
+ return null;
263
+ },
264
+ });
265
+
266
+ /**
267
+ * Restore a soft-deleted taxonomy.
268
+ */
269
+ export const restoreTaxonomy = mutation({
270
+ args: {
271
+ id: v.id("taxonomies"),
272
+ userId: v.optional(v.string()),
273
+ },
274
+ returns: v.id("taxonomies"),
275
+ handler: async (ctx, args) => {
276
+ const { id, userId } = args;
277
+
278
+ const taxonomy = await ctx.db.get(id);
279
+ if (!taxonomy) {
280
+ throw new Error("Taxonomy not found");
281
+ }
282
+
283
+ if (!isDeleted(taxonomy)) {
284
+ return id; // Not deleted
285
+ }
286
+
287
+ await ctx.db.patch(id, {
288
+ deletedAt: undefined,
289
+ isActive: true,
290
+ updatedBy: userId,
291
+ });
292
+
293
+ return id;
294
+ },
295
+ });
296
+
297
+ // =============================================================================
298
+ // Term Mutations
299
+ // =============================================================================
300
+
301
+ /**
302
+ * Create a new taxonomy term.
303
+ *
304
+ * @example
305
+ * ```typescript
306
+ * // Create a flat tag
307
+ * const tagId = await ctx.runMutation(api.taxonomyMutations.createTerm, {
308
+ * taxonomyId: tagsTaxonomyId,
309
+ * name: "JavaScript",
310
+ * });
311
+ *
312
+ * // Create a hierarchical category
313
+ * const categoryId = await ctx.runMutation(api.taxonomyMutations.createTerm, {
314
+ * taxonomyId: categoriesTaxonomyId,
315
+ * name: "Web Development",
316
+ * parentId: techCategoryId,
317
+ * });
318
+ * ```
319
+ */
320
+ export const createTerm = mutation({
321
+ args: {
322
+ taxonomyId: v.id("taxonomies"),
323
+ name: v.string(),
324
+ slug: v.optional(v.string()),
325
+ description: v.optional(v.string()),
326
+ parentId: v.optional(v.id("taxonomyTerms")),
327
+ color: v.optional(v.string()),
328
+ icon: v.optional(v.string()),
329
+ sortOrder: v.optional(v.number()),
330
+ userId: v.optional(v.string()),
331
+ },
332
+ returns: v.id("taxonomyTerms"),
333
+ handler: async (ctx, args) => {
334
+ const {
335
+ taxonomyId,
336
+ name,
337
+ description,
338
+ parentId,
339
+ color,
340
+ icon,
341
+ sortOrder,
342
+ userId,
343
+ } = args;
344
+
345
+ // Verify taxonomy exists
346
+ const taxonomy = await ctx.db.get(taxonomyId);
347
+ if (!taxonomy || isDeleted(taxonomy)) {
348
+ throw new Error("Taxonomy not found");
349
+ }
350
+
351
+ // Check if hierarchy is allowed
352
+ if (parentId && !taxonomy.isHierarchical) {
353
+ throw new Error("Cannot create nested terms in a flat taxonomy");
354
+ }
355
+
356
+ // Verify parent exists if specified
357
+ if (parentId) {
358
+ const parent = await ctx.db.get(parentId);
359
+ if (!parent || isDeleted(parent)) {
360
+ throw new Error("Parent term not found");
361
+ }
362
+ if (parent.taxonomyId !== taxonomyId) {
363
+ throw new Error("Parent term belongs to a different taxonomy");
364
+ }
365
+ }
366
+
367
+ // Generate or validate slug
368
+ const slug = args.slug || generateSlug(name);
369
+
370
+ // Check for duplicate slug in taxonomy
371
+ const existing = await ctx.db
372
+ .query("taxonomyTerms")
373
+ .withIndex("by_taxonomy_and_slug", (q) =>
374
+ q.eq("taxonomyId", taxonomyId).eq("slug", slug),
375
+ )
376
+ .first();
377
+
378
+ if (existing && !isDeleted(existing)) {
379
+ throw new Error(
380
+ `Term with slug "${slug}" already exists in this taxonomy`,
381
+ );
382
+ }
383
+
384
+ // Calculate path and depth
385
+ const path = await buildTermPath(ctx, parentId, slug);
386
+ const depth = await calculateDepth(ctx, parentId);
387
+
388
+ // Build searchText for search index
389
+ const searchText = [name, description].filter(Boolean).join(" ");
390
+
391
+ const termId = await ctx.db.insert("taxonomyTerms", {
392
+ taxonomyId,
393
+ slug,
394
+ name,
395
+ description,
396
+ parentId,
397
+ path,
398
+ depth,
399
+ color,
400
+ icon,
401
+ sortOrder,
402
+ usageCount: 0,
403
+ searchText,
404
+ createdBy: userId,
405
+ });
406
+
407
+ return termId;
408
+ },
409
+ });
410
+
411
+ /**
412
+ * Update an existing term.
413
+ */
414
+ export const updateTerm = mutation({
415
+ args: {
416
+ id: v.id("taxonomyTerms"),
417
+ name: v.optional(v.string()),
418
+ slug: v.optional(v.string()),
419
+ description: v.optional(v.string()),
420
+ parentId: v.optional(v.union(v.id("taxonomyTerms"), v.null())),
421
+ color: v.optional(v.string()),
422
+ icon: v.optional(v.string()),
423
+ sortOrder: v.optional(v.number()),
424
+ userId: v.optional(v.string()),
425
+ },
426
+ returns: v.id("taxonomyTerms"),
427
+ handler: async (ctx, args) => {
428
+ const { id, userId, ...updates } = args;
429
+
430
+ const term = await ctx.db.get(id);
431
+ if (!term) {
432
+ throw new Error("Term not found");
433
+ }
434
+
435
+ if (isDeleted(term)) {
436
+ throw new Error("Cannot update deleted term");
437
+ }
438
+
439
+ const taxonomy = await ctx.db.get(term.taxonomyId);
440
+ if (!taxonomy || isDeleted(taxonomy)) {
441
+ throw new Error("Taxonomy not found");
442
+ }
443
+
444
+ // Build update object
445
+ const updateFields: any = { updatedBy: userId };
446
+
447
+ if (updates.name !== undefined) {
448
+ updateFields.name = updates.name;
449
+ // Update searchText
450
+ updateFields.searchText = [
451
+ updates.name,
452
+ updates.description ?? term.description,
453
+ ]
454
+ .filter(Boolean)
455
+ .join(" ");
456
+ }
457
+
458
+ if (updates.description !== undefined) {
459
+ updateFields.description = updates.description;
460
+ if (!updates.name) {
461
+ updateFields.searchText = [term.name, updates.description]
462
+ .filter(Boolean)
463
+ .join(" ");
464
+ }
465
+ }
466
+
467
+ if (updates.color !== undefined) updateFields.color = updates.color;
468
+ if (updates.icon !== undefined) updateFields.icon = updates.icon;
469
+ if (updates.sortOrder !== undefined)
470
+ updateFields.sortOrder = updates.sortOrder;
471
+
472
+ // Handle slug change
473
+ if (updates.slug !== undefined && updates.slug !== term.slug) {
474
+ // Check for duplicate
475
+ const existing = await ctx.db
476
+ .query("taxonomyTerms")
477
+ .withIndex("by_taxonomy_and_slug", (q) =>
478
+ q.eq("taxonomyId", term.taxonomyId).eq("slug", updates.slug!),
479
+ )
480
+ .first();
481
+
482
+ if (existing && existing._id !== id && !isDeleted(existing)) {
483
+ throw new Error(`Term with slug "${updates.slug}" already exists`);
484
+ }
485
+
486
+ updateFields.slug = updates.slug;
487
+
488
+ // Update path for this term and descendants
489
+ const oldPath = term.path ?? `/${term.slug}`;
490
+ const newPath = await buildTermPath(ctx, term.parentId, updates.slug);
491
+ updateFields.path = newPath;
492
+
493
+ // Update descendants if this term h
494
+ await updateDescendantPaths(ctx, id, oldPath, newPath);
495
+ }
496
+
497
+ // Handle parent change (moving in hierarchy)
498
+ if (updates.parentId !== undefined) {
499
+ const newParentId =
500
+ updates.parentId === null ? undefined : updates.parentId;
501
+
502
+ if (!taxonomy.isHierarchical && newParentId) {
503
+ throw new Error("Cannot create nested terms in a flat taxonomy");
504
+ }
505
+
506
+ // Verify new parent if specified
507
+ if (newParentId) {
508
+ const newParent = await ctx.db.get(newParentId);
509
+ if (!newParent || isDeleted(newParent)) {
510
+ throw new Error("New parent term not found");
511
+ }
512
+ if (newParent.taxonomyId !== term.taxonomyId) {
513
+ throw new Error("New parent belongs to a different taxonomy");
514
+ }
515
+
516
+ // Check for circular reference
517
+ let current: any = newParent;
518
+ while (current) {
519
+ if (current._id === id) {
520
+ throw new Error("Cannot move term under its own descendant");
521
+ }
522
+ if (current.parentId) {
523
+ current = await ctx.db.get(current.parentId);
524
+ } else {
525
+ break;
526
+ }
527
+ }
528
+ }
529
+
530
+ updateFields.parentId = newParentId;
531
+ updateFields.depth = await calculateDepth(ctx, newParentId);
532
+
533
+ // Update path
534
+ const slug = updates.slug ?? term.slug;
535
+ const oldPath = term.path ?? `/${term.slug}`;
536
+ const newPath = await buildTermPath(ctx, newParentId, slug);
537
+ updateFields.path = newPath;
538
+
539
+ // Update descendants
540
+ await updateDescendantPaths(ctx, id, oldPath, newPath);
541
+ }
542
+
543
+ await ctx.db.patch(id, updateFields);
544
+
545
+ return id;
546
+ },
547
+ });
548
+
549
+ /**
550
+ * Soft delete a term.
551
+ */
552
+ export const deleteTerm = mutation({
553
+ args: {
554
+ id: v.id("taxonomyTerms"),
555
+ cascade: v.optional(v.boolean()),
556
+ userId: v.optional(v.string()),
557
+ },
558
+ returns: v.null(),
559
+ handler: async (ctx, args) => {
560
+ const { id, cascade = true, userId } = args;
561
+
562
+ const term = await ctx.db.get(id);
563
+ if (!term) {
564
+ throw new Error("Term not found");
565
+ }
566
+
567
+ if (isDeleted(term)) {
568
+ return null;
569
+ }
570
+
571
+ // Check for children
572
+ const children = await ctx.db
573
+ .query("taxonomyTerms")
574
+ .withIndex("by_parent", (q) => q.eq("parentId", id))
575
+ .collect();
576
+
577
+ const activeChildren = children.filter((c) => !isDeleted(c));
578
+
579
+ if (activeChildren.length > 0 && !cascade) {
580
+ throw new Error(
581
+ "Cannot delete term with children. Use cascade=true or delete children first.",
582
+ );
583
+ }
584
+
585
+ // Delete this term
586
+ await ctx.db.patch(id, {
587
+ deletedAt: Date.now(),
588
+ updatedBy: userId,
589
+ });
590
+
591
+ // Also delete children if cascading
592
+ if (cascade) {
593
+ for (const child of activeChildren) {
594
+ await ctx.db.patch(child._id, {
595
+ deletedAt: Date.now(),
596
+ updatedBy: userId,
597
+ });
598
+ }
599
+ }
600
+
601
+ // Remove entry tag associations
602
+ const associations = await ctx.db
603
+ .query("contentEntryTags")
604
+ .withIndex("by_term", (q) => q.eq("termId", id))
605
+ .collect();
606
+
607
+ for (const assoc of associations) {
608
+ await ctx.db.delete(assoc._id);
609
+ }
610
+
611
+ // Remove media asset tag associations
612
+ const mediaAssociations = await ctx.db
613
+ .query("mediaAssetTags")
614
+ .withIndex("by_term", (q) => q.eq("termId", id))
615
+ .collect();
616
+
617
+ for (const assoc of mediaAssociations) {
618
+ await ctx.db.delete(assoc._id);
619
+ }
620
+
621
+ return null;
622
+ },
623
+ });
624
+
625
+ /**
626
+ * Restore a soft-deleted term.
627
+ */
628
+ export const restoreTerm = mutation({
629
+ args: {
630
+ id: v.id("taxonomyTerms"),
631
+ userId: v.optional(v.string()),
632
+ },
633
+ returns: v.id("taxonomyTerms"),
634
+ handler: async (ctx, args) => {
635
+ const { id, userId } = args;
636
+
637
+ const term = await ctx.db.get(id);
638
+ if (!term) {
639
+ throw new Error("Term not found");
640
+ }
641
+
642
+ if (!isDeleted(term)) {
643
+ return id;
644
+ }
645
+
646
+ // Make sure parent exists if there is one
647
+ if (term.parentId) {
648
+ const parent = await ctx.db.get(term.parentId);
649
+ if (!parent || isDeleted(parent)) {
650
+ // Restore as root term
651
+ await ctx.db.patch(id, {
652
+ deletedAt: undefined,
653
+ parentId: undefined,
654
+ path: `/${term.slug}`,
655
+ depth: 0,
656
+ updatedBy: userId,
657
+ });
658
+ return id;
659
+ }
660
+ }
661
+
662
+ await ctx.db.patch(id, {
663
+ deletedAt: undefined,
664
+ updatedBy: userId,
665
+ });
666
+
667
+ return id;
668
+ },
669
+ });
670
+
671
+ // =============================================================================
672
+ // Entry Tag Mutations
673
+ // =============================================================================
674
+
675
+ /**
676
+ * Set the terms for an entry field (replaces all existing terms).
677
+ *
678
+ * @example
679
+ * ```typescript
680
+ * await ctx.runMutation(api.taxonomyMutations.setEntryTerms, {
681
+ * entryId: blogPostId,
682
+ * fieldName: "tags",
683
+ * termIds: [javascriptTagId, reactTagId, typescriptTagId],
684
+ * });
685
+ * ```
686
+ */
687
+ export const setEntryTerms = mutation({
688
+ args: {
689
+ entryId: v.id("contentEntries"),
690
+ fieldName: v.string(),
691
+ termIds: v.array(v.id("taxonomyTerms")),
692
+ },
693
+ returns: v.null(),
694
+ handler: async (ctx, args) => {
695
+ const { entryId, fieldName, termIds } = args;
696
+
697
+ // Verify entry exists
698
+ const entry = await ctx.db.get(entryId);
699
+ if (!entry || isDeleted(entry)) {
700
+ throw new Error("Content entry not found");
701
+ }
702
+
703
+ // Get existing associations for this field
704
+ const existing = await ctx.db
705
+ .query("contentEntryTags")
706
+ .withIndex("by_entry_and_field", (q) =>
707
+ q.eq("entryId", entryId).eq("fieldName", fieldName),
708
+ )
709
+ .collect();
710
+
711
+ const existingTermIds = new Set(existing.map((e) => e.termId));
712
+ const newTermIds = new Set(termIds);
713
+
714
+ // Calculate terms to remove and add
715
+ const toRemove = existing.filter((e) => !newTermIds.has(e.termId));
716
+ const toAdd = termIds.filter((id) => !existingTermIds.has(id));
717
+
718
+ // Remove old associations and update usage counts
719
+ for (const assoc of toRemove) {
720
+ const term = await ctx.db.get(assoc.termId);
721
+ if (term && term.usageCount > 0) {
722
+ await ctx.db.patch(assoc.termId, {
723
+ usageCount: term.usageCount - 1,
724
+ });
725
+ }
726
+ await ctx.db.delete(assoc._id);
727
+ }
728
+
729
+ // Add new associations and update usage counts
730
+ for (let i = 0; i < toAdd.length; i++) {
731
+ const termId = toAdd[i];
732
+ const term = await ctx.db.get(termId);
733
+ if (!term || isDeleted(term)) {
734
+ continue; // Skip invalid terms
735
+ }
736
+
737
+ // Update usage count
738
+ await ctx.db.patch(termId, {
739
+ usageCount: term.usageCount + 1,
740
+ });
741
+
742
+ // Create association
743
+ await ctx.db.insert("contentEntryTags", {
744
+ entryId,
745
+ termId,
746
+ taxonomyId: term.taxonomyId,
747
+ fieldName,
748
+ sortOrder: i,
749
+ });
750
+ }
751
+
752
+ // Update sort order for existing items that weren't removed
753
+ const remainingExisting = existing.filter((e) => newTermIds.has(e.termId));
754
+ for (const assoc of remainingExisting) {
755
+ const newIndex = termIds.indexOf(assoc.termId);
756
+ if (newIndex !== assoc.sortOrder) {
757
+ await ctx.db.patch(assoc._id, { sortOrder: newIndex });
758
+ }
759
+ }
760
+
761
+ return null;
762
+ },
763
+ });
764
+
765
+ /**
766
+ * Add a single term to an entry field.
767
+ */
768
+ export const addTermToEntry = mutation({
769
+ args: {
770
+ entryId: v.id("contentEntries"),
771
+ fieldName: v.string(),
772
+ termId: v.id("taxonomyTerms"),
773
+ },
774
+ returns: v.null(),
775
+ handler: async (ctx, args) => {
776
+ const { entryId, fieldName, termId } = args;
777
+
778
+ // Verify entry exists
779
+ const entry = await ctx.db.get(entryId);
780
+ if (!entry || isDeleted(entry)) {
781
+ throw new Error("Content entry not found");
782
+ }
783
+
784
+ // Verify term exists
785
+ const term = await ctx.db.get(termId);
786
+ if (!term || isDeleted(term)) {
787
+ throw new Error("Term not found");
788
+ }
789
+
790
+ // Check if already associated
791
+ const existing = await ctx.db
792
+ .query("contentEntryTags")
793
+ .withIndex("by_entry_and_field", (q) =>
794
+ q.eq("entryId", entryId).eq("fieldName", fieldName),
795
+ )
796
+ .collect();
797
+
798
+ if (existing.some((e) => e.termId === termId)) {
799
+ return null; // Already associated
800
+ }
801
+
802
+ // Update usage count
803
+ await ctx.db.patch(termId, {
804
+ usageCount: term.usageCount + 1,
805
+ });
806
+
807
+ // Create association
808
+ await ctx.db.insert("contentEntryTags", {
809
+ entryId,
810
+ termId,
811
+ taxonomyId: term.taxonomyId,
812
+ fieldName,
813
+ sortOrder: existing.length,
814
+ });
815
+
816
+ return null;
817
+ },
818
+ });
819
+
820
+ /**
821
+ * Remove a single term from an entry field.
822
+ */
823
+ export const removeTermFromEntry = mutation({
824
+ args: {
825
+ entryId: v.id("contentEntries"),
826
+ fieldName: v.string(),
827
+ termId: v.id("taxonomyTerms"),
828
+ },
829
+ returns: v.null(),
830
+ handler: async (ctx, args) => {
831
+ const { entryId, fieldName, termId } = args;
832
+
833
+ // Find the association
834
+ const associations = await ctx.db
835
+ .query("contentEntryTags")
836
+ .withIndex("by_entry_and_field", (q) =>
837
+ q.eq("entryId", entryId).eq("fieldName", fieldName),
838
+ )
839
+ .collect();
840
+
841
+ const assoc = associations.find((a) => a.termId === termId);
842
+ if (!assoc) {
843
+ return null; // Not associated
844
+ }
845
+
846
+ // Update usage count
847
+ const term = await ctx.db.get(termId);
848
+ if (term && term.usageCount > 0) {
849
+ await ctx.db.patch(termId, {
850
+ usageCount: term.usageCount - 1,
851
+ });
852
+ }
853
+
854
+ // Delete association
855
+ await ctx.db.delete(assoc._id);
856
+
857
+ return null;
858
+ },
859
+ });
860
+
861
+ /**
862
+ * Create a term and add it to an entry in one operation.
863
+ * Useful for inline tag creation.
864
+ */
865
+ export const createTermAndAddToEntry = mutation({
866
+ args: {
867
+ taxonomyId: v.id("taxonomies"),
868
+ name: v.string(),
869
+ entryId: v.id("contentEntries"),
870
+ fieldName: v.string(),
871
+ userId: v.optional(v.string()),
872
+ },
873
+ returns: v.id("taxonomyTerms"),
874
+ handler: async (ctx, args) => {
875
+ const { taxonomyId, name, entryId, fieldName, userId } = args;
876
+
877
+ // Verify taxonomy allows inline creation
878
+ const taxonomy = await ctx.db.get(taxonomyId);
879
+ if (!taxonomy || isDeleted(taxonomy)) {
880
+ throw new Error("Taxonomy not found");
881
+ }
882
+
883
+ if (!taxonomy.allowInlineCreation) {
884
+ throw new Error("Inline term creation is not allowed for this taxonomy");
885
+ }
886
+
887
+ // Generate slug
888
+ const slug = generateSlug(name);
889
+
890
+ // Check if term already exists
891
+ const existingTerm = await ctx.db
892
+ .query("taxonomyTerms")
893
+ .withIndex("by_taxonomy_and_slug", (q) =>
894
+ q.eq("taxonomyId", taxonomyId).eq("slug", slug),
895
+ )
896
+ .first();
897
+
898
+ let termId: Id<"taxonomyTerms">;
899
+
900
+ if (existingTerm && !isDeleted(existingTerm)) {
901
+ // Use existing term
902
+ termId = existingTerm._id;
903
+ } else if (existingTerm) {
904
+ // Restore soft-deleted term
905
+ await ctx.db.patch(existingTerm._id, {
906
+ deletedAt: undefined,
907
+ name,
908
+ searchText: name,
909
+ updatedBy: userId,
910
+ });
911
+ termId = existingTerm._id;
912
+ } else {
913
+ // Create new term
914
+ termId = await ctx.db.insert("taxonomyTerms", {
915
+ taxonomyId,
916
+ slug,
917
+ name,
918
+ depth: 0,
919
+ usageCount: 0,
920
+ searchText: name,
921
+ createdBy: userId,
922
+ });
923
+ }
924
+
925
+ // Add to entry
926
+ const existingAssoc = await ctx.db
927
+ .query("contentEntryTags")
928
+ .withIndex("by_entry_and_field", (q) =>
929
+ q.eq("entryId", entryId).eq("fieldName", fieldName),
930
+ )
931
+ .collect();
932
+
933
+ if (!existingAssoc.some((a) => a.termId === termId)) {
934
+ // Get the term for usage count update
935
+ const termDoc = await ctx.db.get(termId);
936
+ if (termDoc) {
937
+ await ctx.db.patch(termId, {
938
+ usageCount: termDoc.usageCount + 1,
939
+ });
940
+ }
941
+
942
+ await ctx.db.insert("contentEntryTags", {
943
+ entryId,
944
+ termId,
945
+ taxonomyId,
946
+ fieldName,
947
+ sortOrder: existingAssoc.length,
948
+ });
949
+ }
950
+
951
+ return termId;
952
+ },
953
+ });
954
+
955
+ // =============================================================================
956
+ // Media Asset Tag Mutations
957
+ // =============================================================================
958
+
959
+ /**
960
+ * Set the terms for a media asset in a taxonomy (replaces all existing terms).
961
+ *
962
+ * @example
963
+ * ```typescript
964
+ * await ctx.runMutation(api.taxonomyMutations.setMediaTerms, {
965
+ * mediaId: imageId,
966
+ * taxonomyId: categoriesTaxonomyId,
967
+ * termIds: [landscapeTagId, summerTagId],
968
+ * });
969
+ * ```
970
+ */
971
+ export const setMediaTerms = mutation({
972
+ args: {
973
+ mediaId: v.id("mediaItems"),
974
+ taxonomyId: v.id("taxonomies"),
975
+ termIds: v.array(v.id("taxonomyTerms")),
976
+ },
977
+ returns: v.null(),
978
+ handler: async (ctx, args) => {
979
+ const { mediaId, taxonomyId, termIds } = args;
980
+
981
+ const media = await ctx.db.get(mediaId);
982
+ if (!media || isDeleted(media)) {
983
+ throw new Error("Media asset not found");
984
+ }
985
+
986
+ const taxonomy = await ctx.db.get(taxonomyId);
987
+ if (!taxonomy || isDeleted(taxonomy)) {
988
+ throw new Error("Taxonomy not found");
989
+ }
990
+
991
+ const existing = await ctx.db
992
+ .query("mediaAssetTags")
993
+ .withIndex("by_media_and_taxonomy", (q) =>
994
+ q.eq("mediaId", mediaId).eq("taxonomyId", taxonomyId),
995
+ )
996
+ .collect();
997
+
998
+ const existingTermIds = new Set(existing.map((e) => e.termId));
999
+ const newTermIds = new Set(termIds);
1000
+
1001
+ const toRemove = existing.filter((e) => !newTermIds.has(e.termId));
1002
+ const toAdd = termIds.filter((id) => !existingTermIds.has(id));
1003
+
1004
+ for (const assoc of toRemove) {
1005
+ const term = await ctx.db.get(assoc.termId);
1006
+ if (term && term.usageCount > 0) {
1007
+ await ctx.db.patch(assoc.termId, {
1008
+ usageCount: term.usageCount - 1,
1009
+ });
1010
+ }
1011
+ await ctx.db.delete(assoc._id);
1012
+ }
1013
+
1014
+ for (let i = 0; i < toAdd.length; i++) {
1015
+ const termId = toAdd[i];
1016
+ const term = await ctx.db.get(termId);
1017
+ if (!term || isDeleted(term)) {
1018
+ continue;
1019
+ }
1020
+
1021
+ if (term.taxonomyId !== taxonomyId) {
1022
+ continue;
1023
+ }
1024
+
1025
+ await ctx.db.patch(termId, {
1026
+ usageCount: term.usageCount + 1,
1027
+ });
1028
+
1029
+ await ctx.db.insert("mediaAssetTags", {
1030
+ mediaId,
1031
+ termId,
1032
+ taxonomyId,
1033
+ sortOrder: i,
1034
+ });
1035
+ }
1036
+
1037
+ const remainingExisting = existing.filter((e) => newTermIds.has(e.termId));
1038
+ for (const assoc of remainingExisting) {
1039
+ const newIndex = termIds.indexOf(assoc.termId);
1040
+ if (newIndex !== assoc.sortOrder) {
1041
+ await ctx.db.patch(assoc._id, { sortOrder: newIndex });
1042
+ }
1043
+ }
1044
+
1045
+ return null;
1046
+ },
1047
+ });
1048
+
1049
+ /**
1050
+ * Add a single term to a media asset.
1051
+ */
1052
+ export const addTermToMedia = mutation({
1053
+ args: {
1054
+ mediaId: v.id("mediaItems"),
1055
+ termId: v.id("taxonomyTerms"),
1056
+ },
1057
+ returns: v.null(),
1058
+ handler: async (ctx, args) => {
1059
+ const { mediaId, termId } = args;
1060
+
1061
+ const media = await ctx.db.get(mediaId);
1062
+ if (!media || isDeleted(media)) {
1063
+ throw new Error("Media asset not found");
1064
+ }
1065
+
1066
+ const term = await ctx.db.get(termId);
1067
+ if (!term || isDeleted(term)) {
1068
+ throw new Error("Term not found");
1069
+ }
1070
+
1071
+ const existing = await ctx.db
1072
+ .query("mediaAssetTags")
1073
+ .withIndex("by_media", (q) => q.eq("mediaId", mediaId))
1074
+ .collect();
1075
+
1076
+ if (existing.some((e) => e.termId === termId)) {
1077
+ return null;
1078
+ }
1079
+
1080
+ await ctx.db.patch(termId, {
1081
+ usageCount: term.usageCount + 1,
1082
+ });
1083
+
1084
+ await ctx.db.insert("mediaAssetTags", {
1085
+ mediaId,
1086
+ termId,
1087
+ taxonomyId: term.taxonomyId,
1088
+ sortOrder: existing.length,
1089
+ });
1090
+
1091
+ return null;
1092
+ },
1093
+ });
1094
+
1095
+ /**
1096
+ * Remove a single term from a media asset.
1097
+ */
1098
+ export const removeTermFromMedia = mutation({
1099
+ args: {
1100
+ mediaId: v.id("mediaItems"),
1101
+ termId: v.id("taxonomyTerms"),
1102
+ },
1103
+ returns: v.null(),
1104
+ handler: async (ctx, args) => {
1105
+ const { mediaId, termId } = args;
1106
+
1107
+ const associations = await ctx.db
1108
+ .query("mediaAssetTags")
1109
+ .withIndex("by_media", (q) => q.eq("mediaId", mediaId))
1110
+ .collect();
1111
+
1112
+ const assoc = associations.find((a) => a.termId === termId);
1113
+ if (!assoc) {
1114
+ return null;
1115
+ }
1116
+
1117
+ const term = await ctx.db.get(termId);
1118
+ if (term && term.usageCount > 0) {
1119
+ await ctx.db.patch(termId, {
1120
+ usageCount: term.usageCount - 1,
1121
+ });
1122
+ }
1123
+
1124
+ await ctx.db.delete(assoc._id);
1125
+
1126
+ return null;
1127
+ },
1128
+ });
1129
+
1130
+ /**
1131
+ * Create a term and add it to a media asset in one operation.
1132
+ * Useful for inline tag creation in the media library.
1133
+ */
1134
+ export const createTermAndAddToMedia = mutation({
1135
+ args: {
1136
+ taxonomyId: v.id("taxonomies"),
1137
+ name: v.string(),
1138
+ mediaId: v.id("mediaItems"),
1139
+ userId: v.optional(v.string()),
1140
+ },
1141
+ returns: v.id("taxonomyTerms"),
1142
+ handler: async (ctx, args) => {
1143
+ const { taxonomyId, name, mediaId, userId } = args;
1144
+
1145
+ const taxonomy = await ctx.db.get(taxonomyId);
1146
+ if (!taxonomy || isDeleted(taxonomy)) {
1147
+ throw new Error("Taxonomy not found");
1148
+ }
1149
+
1150
+ if (!taxonomy.allowInlineCreation) {
1151
+ throw new Error("Inline term creation is not allowed for this taxonomy");
1152
+ }
1153
+
1154
+ const slug = generateSlug(name);
1155
+
1156
+ const existingTerm = await ctx.db
1157
+ .query("taxonomyTerms")
1158
+ .withIndex("by_taxonomy_and_slug", (q) =>
1159
+ q.eq("taxonomyId", taxonomyId).eq("slug", slug),
1160
+ )
1161
+ .first();
1162
+
1163
+ let termId: Id<"taxonomyTerms">;
1164
+
1165
+ if (existingTerm && !isDeleted(existingTerm)) {
1166
+ termId = existingTerm._id;
1167
+ } else if (existingTerm) {
1168
+ await ctx.db.patch(existingTerm._id, {
1169
+ deletedAt: undefined,
1170
+ name,
1171
+ searchText: name,
1172
+ updatedBy: userId,
1173
+ });
1174
+ termId = existingTerm._id;
1175
+ } else {
1176
+ termId = await ctx.db.insert("taxonomyTerms", {
1177
+ taxonomyId,
1178
+ slug,
1179
+ name,
1180
+ depth: 0,
1181
+ usageCount: 0,
1182
+ searchText: name,
1183
+ createdBy: userId,
1184
+ });
1185
+ }
1186
+
1187
+ const existingAssoc = await ctx.db
1188
+ .query("mediaAssetTags")
1189
+ .withIndex("by_media", (q) => q.eq("mediaId", mediaId))
1190
+ .collect();
1191
+
1192
+ if (!existingAssoc.some((a) => a.termId === termId)) {
1193
+ const termDoc = await ctx.db.get(termId);
1194
+ if (termDoc) {
1195
+ await ctx.db.patch(termId, {
1196
+ usageCount: termDoc.usageCount + 1,
1197
+ });
1198
+ }
1199
+
1200
+ await ctx.db.insert("mediaAssetTags", {
1201
+ mediaId,
1202
+ termId,
1203
+ taxonomyId,
1204
+ sortOrder: existingAssoc.length,
1205
+ });
1206
+ }
1207
+
1208
+ return termId;
1209
+ },
1210
+ });