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,1046 @@
1
+ /**
2
+ * Media Folder Mutation Functions
3
+ *
4
+ * Provides mutation functions for creating, updating, moving, and deleting media folders.
5
+ * Media folders organize media assets into a hierarchical structure with path validation.
6
+ *
7
+ * Folder Hierarchy:
8
+ * - Root folders have no parentId
9
+ * - Nested folders reference their parent folder
10
+ * - Path is automatically computed from the folder hierarchy (e.g., "/images/blog/2026")
11
+ * - Moving folders updates paths for the folder and all descendants
12
+ */
13
+
14
+ import { mutation, query, type MutationCtx } from "./_generated/server.js";
15
+ import type { Id } from "./_generated/dataModel.js";
16
+ import { v } from "convex/values";
17
+ import {
18
+ createMediaFolderArgs,
19
+ updateMediaFolderArgs,
20
+ moveFolderArgs,
21
+ mediaItemDoc,
22
+ mutationAuthContext,
23
+ } from "./validators.js";
24
+ import {
25
+ mediaFolderNotFound,
26
+ mediaFolderDeleted,
27
+ mediaFolderNotDeleted,
28
+ mediaFolderNameInvalid,
29
+ mediaFolderNameDuplicate,
30
+ mediaFolderDepthExceeded,
31
+ mediaFolderPathTooLong,
32
+ mediaFolderHasContents,
33
+ mediaFolderCircularMove,
34
+ mediaFolderParentDeleted,
35
+ mediaFolderCreateFailed,
36
+ internalError,
37
+ } from "./lib/errors.js";
38
+ import { requireMutationAuth } from "./lib/mutationAuth.js";
39
+ import { isDeleted } from "./lib/softDelete.js";
40
+
41
+ // =============================================================================
42
+ // Constants
43
+ // =============================================================================
44
+
45
+ /**
46
+ * Maximum depth of folder nesting.
47
+ * Prevents excessively deep hierarchies that could impact performance.
48
+ */
49
+ const MAX_FOLDER_DEPTH = 10;
50
+
51
+ /**
52
+ * Maximum length of a folder path.
53
+ * Prevents extremely long paths that could cause issues.
54
+ */
55
+ const MAX_PATH_LENGTH = 500;
56
+
57
+ /**
58
+ * Invalid characters in folder names.
59
+ * These characters are not allowed because they could break path parsing.
60
+ */
61
+ const INVALID_NAME_CHARS = /[/\\:*?"<>|]/;
62
+
63
+ // =============================================================================
64
+ // Helper Functions
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Validates a folder name.
69
+ *
70
+ * @param name - The folder name to validate
71
+ * @throws Error if the name is invalid
72
+ */
73
+ function validateFolderName(name: string): void {
74
+ // Check for empty or whitespace-only names
75
+ const trimmed = name.trim();
76
+ if (trimmed.length === 0) {
77
+ throw mediaFolderNameInvalid(name, "Name cannot be empty");
78
+ }
79
+
80
+ // Check for invalid characters
81
+ if (INVALID_NAME_CHARS.test(trimmed)) {
82
+ throw mediaFolderNameInvalid(
83
+ name,
84
+ `Contains invalid characters. The following are not allowed: / \\ : * ? " < > |`,
85
+ );
86
+ }
87
+
88
+ // Check length
89
+ if (trimmed.length > 255) {
90
+ throw mediaFolderNameInvalid(name, "Name cannot exceed 255 characters");
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Builds the full path for a folder based on its parent.
96
+ *
97
+ * @param name - The folder name
98
+ * @param parentPath - The parent folder's path (empty string for root)
99
+ * @returns The full path for the folder
100
+ */
101
+ function buildFolderPath(name: string, parentPath: string): string {
102
+ const trimmedName = name.trim();
103
+ if (!parentPath || parentPath === "/") {
104
+ return `/${trimmedName}`;
105
+ }
106
+ return `${parentPath}/${trimmedName}`;
107
+ }
108
+
109
+ /**
110
+ * Calculates the depth of a path.
111
+ *
112
+ * @param path - The folder path
113
+ * @returns The depth (number of segments)
114
+ */
115
+ function getPathDepth(path: string): number {
116
+ if (!path || path === "/") return 0;
117
+ // Split by "/" and filter out empty strings
118
+ return path.split("/").filter((segment) => segment.length > 0).length;
119
+ }
120
+
121
+ // =============================================================================
122
+ // Create Media Folder Mutation
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Mutation to create a new media folder.
127
+ *
128
+ * Creates a folder in the media library hierarchy. Folders can be nested
129
+ * within other folders up to MAX_FOLDER_DEPTH levels deep. The full path
130
+ * is automatically computed based on the parent folder hierarchy.
131
+ *
132
+ * Validation Rules:
133
+ * - Folder name must not be empty or whitespace-only
134
+ * - Folder name must not contain: / \ : * ? " < > |
135
+ * - Folder name must not exceed 255 characters
136
+ * - Full path must not exceed MAX_PATH_LENGTH characters
137
+ * - Folder depth must not exceed MAX_FOLDER_DEPTH levels
138
+ * - Parent folder must exist and not be soft-deleted (if provided)
139
+ * - Folder name must be unique within the parent folder
140
+ *
141
+ * @param name - The folder name (required)
142
+ * @param parentId - Optional parent folder ID for nesting
143
+ * @param description - Optional description of the folder
144
+ * @param sortOrder - Optional custom sort order
145
+ * @param createdBy - Optional user ID for audit trail
146
+ *
147
+ * @returns The created media folder document
148
+ *
149
+ * @throws Error if the folder name is invalid
150
+ * @throws Error if the parent folder does not exist
151
+ * @throws Error if the parent folder has been deleted
152
+ * @throws Error if the folder depth would exceed the maximum
153
+ * @throws Error if the path length would exceed the maximum
154
+ * @throws Error if a folder with the same name already exists in the parent
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * // Create a root folder
159
+ * const imagesFolder = await ctx.runMutation(api.mediaFolderMutations.createMediaFolder, {
160
+ * name: "Images",
161
+ * description: "All image assets",
162
+ * createdBy: currentUserId,
163
+ * });
164
+ *
165
+ * // Create a nested folder
166
+ * const blogFolder = await ctx.runMutation(api.mediaFolderMutations.createMediaFolder, {
167
+ * name: "Blog",
168
+ * parentId: imagesFolder._id,
169
+ * description: "Blog post images",
170
+ * createdBy: currentUserId,
171
+ * });
172
+ *
173
+ * // Result: blogFolder.path === "/Images/Blog"
174
+ * ```
175
+ */
176
+ export const createMediaFolder = mutation({
177
+ args: {
178
+ ...createMediaFolderArgs.fields,
179
+ /** Optional auth context for mutation-level authorization */
180
+ _auth: v.optional(mutationAuthContext),
181
+ },
182
+ returns: mediaItemDoc,
183
+ handler: async (ctx, args) => {
184
+ const { name, parentId, description, sortOrder, createdBy, _auth } = args;
185
+
186
+ // Authorization check - mediaFolders.create permission
187
+ requireMutationAuth(_auth, "mediaItems", "create");
188
+
189
+ // Validate folder name
190
+ validateFolderName(name);
191
+
192
+ // Determine parent path and validate parent folder
193
+ let parentPath = "";
194
+
195
+ if (parentId !== undefined) {
196
+ // Fetch parent folder
197
+ const parentFolder = await ctx.db.get(parentId);
198
+
199
+ if (!parentFolder) {
200
+ throw mediaFolderNotFound((parentId as unknown) as string);
201
+ }
202
+
203
+ if (isDeleted(parentFolder)) {
204
+ throw mediaFolderDeleted((parentId as unknown) as string);
205
+ }
206
+
207
+ parentPath = parentFolder.path;
208
+
209
+ // Check folder depth limit
210
+ const parentDepth = getPathDepth(parentPath);
211
+ if (parentDepth >= MAX_FOLDER_DEPTH) {
212
+ throw mediaFolderDepthExceeded(MAX_FOLDER_DEPTH, parentDepth + 1);
213
+ }
214
+ }
215
+
216
+ // Build the full path
217
+ const path = buildFolderPath(name, parentPath);
218
+
219
+ // Check path length limit
220
+ if (path.length > MAX_PATH_LENGTH) {
221
+ throw mediaFolderPathTooLong(MAX_PATH_LENGTH, path.length);
222
+ }
223
+
224
+ // Check for duplicate folder name in the same parent
225
+ const existingFolder = await ctx.db
226
+ .query("mediaItems")
227
+ .withIndex("by_path", (q) => q.eq("path", path))
228
+ .filter((q) => q.and(
229
+ q.eq(q.field("kind"), "folder"),
230
+ q.eq(q.field("deletedAt"), undefined)
231
+ ))
232
+ .first();
233
+
234
+ if (existingFolder) {
235
+ throw mediaFolderNameDuplicate(name.trim(), parentPath || undefined);
236
+ }
237
+
238
+ // Create the folder
239
+ const folderId = await ctx.db.insert("mediaItems", {
240
+ kind: "folder",
241
+ name: name.trim(),
242
+ parentId,
243
+ path,
244
+ description,
245
+ sortOrder,
246
+ createdBy,
247
+ });
248
+
249
+ // Retrieve and return the created folder
250
+ const folder = await ctx.db.get(folderId);
251
+ if (!folder) {
252
+ throw mediaFolderCreateFailed();
253
+ }
254
+
255
+ return folder;
256
+ },
257
+ });
258
+
259
+ // =============================================================================
260
+ // Update Media Folder Mutation
261
+ // =============================================================================
262
+
263
+ /**
264
+ * Mutation to update a media folder's metadata.
265
+ *
266
+ * Updates the folder name, description, or sort order. If the name is changed,
267
+ * the path is automatically updated for this folder and all its descendants.
268
+ *
269
+ * @param id - The folder ID to update
270
+ * @param name - Optional new folder name
271
+ * @param description - Optional new description
272
+ * @param sortOrder - Optional new sort order
273
+ *
274
+ * @returns The updated media folder document
275
+ *
276
+ * @throws Error if the folder does not exist
277
+ * @throws Error if the folder has been deleted
278
+ * @throws Error if the new name is invalid
279
+ * @throws Error if a folder with the new name already exists in the same parent
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const updated = await ctx.runMutation(api.mediaFolderMutations.updateMediaFolder, {
284
+ * id: folderId,
285
+ * name: "Blog Images",
286
+ * description: "Updated description",
287
+ * });
288
+ * ```
289
+ */
290
+ export const updateMediaFolder = mutation({
291
+ args: {
292
+ ...updateMediaFolderArgs.fields,
293
+ /** Optional auth context for mutation-level authorization */
294
+ _auth: v.optional(mutationAuthContext),
295
+ },
296
+ returns: mediaItemDoc,
297
+ handler: async (ctx, args) => {
298
+ const { id, name, description, sortOrder, _auth } = args;
299
+
300
+ // Authorization check - mediaFolders.update permission
301
+ requireMutationAuth(_auth, "mediaItems", "update");
302
+
303
+ const folder = await ctx.db.get(id);
304
+
305
+ if (!folder) {
306
+ throw mediaFolderNotFound((id as unknown) as string);
307
+ }
308
+
309
+ if (isDeleted(folder)) {
310
+ throw mediaFolderDeleted((id as unknown) as string);
311
+ }
312
+
313
+ // Build updates object
314
+ const updates: Record<string, unknown> = {};
315
+
316
+ // Handle name change (requires path update)
317
+ if (name !== undefined && name.trim() !== folder.name) {
318
+ validateFolderName(name);
319
+
320
+ // Get parent path
321
+ let parentPath = "";
322
+ if (folder.parentId) {
323
+ const parentFolder = await ctx.db.get(folder.parentId);
324
+ if (parentFolder) {
325
+ parentPath = parentFolder.path;
326
+ }
327
+ }
328
+
329
+ // Build new path
330
+ const newPath = buildFolderPath(name, parentPath);
331
+
332
+ // Check path length
333
+ if (newPath.length > MAX_PATH_LENGTH) {
334
+ throw mediaFolderPathTooLong(MAX_PATH_LENGTH, newPath.length);
335
+ }
336
+
337
+ // Check for duplicate name in same parent
338
+ const existingFolder = await ctx.db
339
+ .query("mediaItems")
340
+ .withIndex("by_path", (q) => q.eq("path", newPath))
341
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
342
+ .first();
343
+
344
+ if (existingFolder && existingFolder._id !== id) {
345
+ throw mediaFolderNameDuplicate(name.trim(), parentPath || undefined);
346
+ }
347
+
348
+ updates.name = name.trim();
349
+ const oldPath = folder.path;
350
+ updates.path = newPath;
351
+
352
+ // Update all descendant folder paths
353
+ await updateDescendantPaths(ctx, oldPath, newPath);
354
+ }
355
+
356
+ if (description !== undefined) {
357
+ updates.description = description;
358
+ }
359
+
360
+ if (sortOrder !== undefined) {
361
+ updates.sortOrder = sortOrder;
362
+ }
363
+
364
+ // Apply updates if any
365
+ if (Object.keys(updates).length > 0) {
366
+ await ctx.db.patch(id, updates);
367
+ }
368
+
369
+ // Retrieve and return the updated folder
370
+ const updatedFolder = await ctx.db.get(id);
371
+ if (!updatedFolder) {
372
+ throw new Error("Failed to retrieve updated media folder");
373
+ }
374
+
375
+ return updatedFolder;
376
+ },
377
+ });
378
+
379
+ /**
380
+ * Updates paths for all descendant folders when a parent folder is renamed.
381
+ */
382
+ async function updateDescendantPaths(
383
+ ctx: MutationCtx,
384
+ oldParentPath: string,
385
+ newParentPath: string,
386
+ ): Promise<void> {
387
+ // Find all folders whose path starts with the old path
388
+ const descendants = await ctx.db
389
+ .query("mediaItems")
390
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
391
+ .collect();
392
+
393
+ for (const descendant of descendants) {
394
+ if (
395
+ descendant.path.startsWith(oldParentPath + "/") &&
396
+ descendant.path !== oldParentPath
397
+ ) {
398
+ const newDescendantPath = descendant.path.replace(
399
+ oldParentPath,
400
+ newParentPath,
401
+ );
402
+ await ctx.db.patch(descendant._id, { path: newDescendantPath });
403
+ }
404
+ }
405
+ }
406
+
407
+ // =============================================================================
408
+ // Move Media Folder Mutation
409
+ // =============================================================================
410
+
411
+ /**
412
+ * Mutation to move a folder to a different parent.
413
+ *
414
+ * Moves a folder and all its contents (assets and subfolders) to a new
415
+ * location in the hierarchy. Updates paths for the folder and all descendants.
416
+ *
417
+ * @param id - The folder ID to move
418
+ * @param newParentId - The new parent folder ID (undefined for root level)
419
+ *
420
+ * @returns The moved media folder document
421
+ *
422
+ * @throws Error if the folder does not exist
423
+ * @throws Error if the folder has been deleted
424
+ * @throws Error if the new parent does not exist
425
+ * @throws Error if the new parent has been deleted
426
+ * @throws Error if moving would create a circular reference
427
+ * @throws Error if moving would exceed the maximum depth
428
+ *
429
+ * @example
430
+ * ```typescript
431
+ * // Move folder to a different parent
432
+ * const moved = await ctx.runMutation(api.mediaFolderMutations.moveMediaFolder, {
433
+ * id: folderId,
434
+ * newParentId: newParentFolderId,
435
+ * });
436
+ *
437
+ * // Move folder to root level
438
+ * const movedToRoot = await ctx.runMutation(api.mediaFolderMutations.moveMediaFolder, {
439
+ * id: folderId,
440
+ * newParentId: undefined,
441
+ * });
442
+ * ```
443
+ */
444
+ export const moveMediaFolder = mutation({
445
+ args: {
446
+ ...moveFolderArgs.fields,
447
+ /** Optional auth context for mutation-level authorization */
448
+ _auth: v.optional(mutationAuthContext),
449
+ },
450
+ returns: mediaItemDoc,
451
+ handler: async (ctx, args) => {
452
+ const { id, newParentId, _auth } = args;
453
+
454
+ // Authorization check - mediaFolders.update permission (move is a form of update)
455
+ requireMutationAuth(_auth, "mediaItems", "update");
456
+
457
+ const folder = await ctx.db.get(id);
458
+
459
+ if (!folder) {
460
+ throw mediaFolderNotFound((id as unknown) as string);
461
+ }
462
+
463
+ if (isDeleted(folder)) {
464
+ throw mediaFolderDeleted((id as unknown) as string);
465
+ }
466
+
467
+ // No change needed if parent is the same
468
+ if (folder.parentId === newParentId) {
469
+ return folder;
470
+ }
471
+
472
+ // Determine new parent path
473
+ let newParentPath = "";
474
+
475
+ if (newParentId !== undefined) {
476
+ // Fetch new parent folder
477
+ const newParentFolder = await ctx.db.get(newParentId);
478
+
479
+ if (!newParentFolder) {
480
+ throw mediaFolderNotFound((newParentId as unknown) as string);
481
+ }
482
+
483
+ if (isDeleted(newParentFolder)) {
484
+ throw mediaFolderDeleted((newParentId as unknown) as string);
485
+ }
486
+
487
+ // Check for circular reference
488
+ // Cannot move a folder into itself or one of its descendants
489
+ if (
490
+ newParentFolder.path.startsWith(folder.path + "/") ||
491
+ newParentFolder._id === id
492
+ ) {
493
+ throw mediaFolderCircularMove((id as unknown) as string);
494
+ }
495
+
496
+ newParentPath = newParentFolder.path;
497
+
498
+ // Check depth limit
499
+ const newParentDepth = getPathDepth(newParentPath);
500
+ const folderSubtreeDepth = await getMaxSubtreeDepth(ctx, folder.path);
501
+ const totalDepth = newParentDepth + 1 + folderSubtreeDepth;
502
+
503
+ if (totalDepth > MAX_FOLDER_DEPTH) {
504
+ throw mediaFolderDepthExceeded(MAX_FOLDER_DEPTH, totalDepth);
505
+ }
506
+ }
507
+
508
+ // Build new path
509
+ const oldPath = folder.path;
510
+ const newPath = buildFolderPath(folder.name, newParentPath);
511
+
512
+ // Check path length
513
+ if (newPath.length > MAX_PATH_LENGTH) {
514
+ throw mediaFolderPathTooLong(MAX_PATH_LENGTH, newPath.length);
515
+ }
516
+
517
+ // Check for duplicate name in new parent
518
+ const existingFolder = await ctx.db
519
+ .query("mediaItems")
520
+ .withIndex("by_path", (q) => q.eq("path", newPath))
521
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
522
+ .first();
523
+
524
+ if (existingFolder && existingFolder._id !== id) {
525
+ throw mediaFolderNameDuplicate(folder.name, newParentPath || undefined);
526
+ }
527
+
528
+ // Update the folder
529
+ await ctx.db.patch(id, {
530
+ parentId: newParentId,
531
+ path: newPath,
532
+ });
533
+
534
+ // Update all descendant folder paths
535
+ await updateDescendantPaths(ctx, oldPath, newPath);
536
+
537
+ // Retrieve and return the moved folder
538
+ const movedFolder = await ctx.db.get(id);
539
+ if (!movedFolder) {
540
+ throw internalError("Failed to retrieve moved media folder");
541
+ }
542
+
543
+ return movedFolder;
544
+ },
545
+ });
546
+
547
+ /**
548
+ * Gets the maximum depth of descendants under a folder path.
549
+ */
550
+ async function getMaxSubtreeDepth(
551
+ ctx: MutationCtx,
552
+ folderPath: string,
553
+ ): Promise<number> {
554
+ const descendants = await ctx.db
555
+ .query("mediaItems")
556
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
557
+ .collect();
558
+
559
+ let maxDepth = 0;
560
+ const baseDepth = getPathDepth(folderPath);
561
+
562
+ for (const descendant of descendants) {
563
+ if (descendant.path.startsWith(folderPath + "/")) {
564
+ const descendantDepth = getPathDepth(descendant.path);
565
+ const relativeDepth = descendantDepth - baseDepth;
566
+ if (relativeDepth > maxDepth) {
567
+ maxDepth = relativeDepth;
568
+ }
569
+ }
570
+ }
571
+
572
+ return maxDepth;
573
+ }
574
+
575
+ // =============================================================================
576
+ // Delete Media Folder Mutation
577
+ // =============================================================================
578
+
579
+ /**
580
+ * Validator for delete folder arguments.
581
+ */
582
+ export const deleteMediaFolderArgs = {
583
+ id: v.id("mediaItems"),
584
+ deletedBy: v.optional(v.string()),
585
+ hardDelete: v.optional(v.boolean()),
586
+ recursive: v.optional(v.boolean()),
587
+ };
588
+
589
+ /**
590
+ * Mutation to delete a media folder.
591
+ *
592
+ * Supports two modes:
593
+ * - Soft delete (default): Sets deletedAt timestamp, folder can be restored
594
+ * - Hard delete: Permanently removes the folder from the database
595
+ *
596
+ * By default, deletion fails if the folder contains assets or subfolders.
597
+ * Use recursive: true to delete the folder and all its contents.
598
+ *
599
+ * @param id - The folder ID to delete
600
+ * @param deletedBy - Optional user ID for audit trail
601
+ * @param hardDelete - If true, permanently deletes the folder
602
+ * @param recursive - If true, deletes folder and all contents
603
+ *
604
+ * @returns The deleted media folder document
605
+ *
606
+ * @throws Error if the folder does not exist
607
+ * @throws Error if the folder is already deleted (for soft delete)
608
+ * @throws Error if the folder has contents and recursive is not true
609
+ *
610
+ * @example
611
+ * ```typescript
612
+ * // Soft delete an empty folder
613
+ * const deleted = await ctx.runMutation(api.mediaFolderMutations.deleteMediaFolder, {
614
+ * id: folderId,
615
+ * deletedBy: currentUserId,
616
+ * });
617
+ *
618
+ * // Recursively delete folder and all contents
619
+ * const deleted = await ctx.runMutation(api.mediaFolderMutations.deleteMediaFolder, {
620
+ * id: folderId,
621
+ * deletedBy: currentUserId,
622
+ * recursive: true,
623
+ * });
624
+ * ```
625
+ */
626
+ export const deleteMediaFolder = mutation({
627
+ args: {
628
+ ...deleteMediaFolderArgs,
629
+ /** Optional auth context for mutation-level authorization */
630
+ _auth: v.optional(mutationAuthContext),
631
+ },
632
+ returns: mediaItemDoc,
633
+ handler: async (ctx, args) => {
634
+ const {
635
+ id,
636
+ // deletedBy,
637
+ hardDelete = false,
638
+ recursive = false,
639
+ _auth,
640
+ } = args;
641
+
642
+ // Authorization check - mediaFolders.delete permission
643
+ requireMutationAuth(_auth, "mediaItems", "delete");
644
+
645
+ const folder = await ctx.db.get(id);
646
+
647
+ if (!folder) {
648
+ throw mediaFolderNotFound((id as unknown) as string);
649
+ }
650
+
651
+ // For soft delete, check if already deleted
652
+ if (!hardDelete && isDeleted(folder)) {
653
+ throw mediaFolderDeleted((id as unknown) as string);
654
+ }
655
+
656
+ // Check for contents (subfolders and assets)
657
+ const subfolders = await ctx.db
658
+ .query("mediaItems")
659
+ .withIndex("by_parent", (q) => q.eq("parentId", id))
660
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
661
+ .take(1);
662
+
663
+ const assets = await ctx.db
664
+ .query("mediaItems")
665
+ .withIndex("by_parent", (q) => q.eq("parentId", id))
666
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
667
+ .take(1);
668
+
669
+ const hasContents = subfolders.length > 0 || assets.length > 0;
670
+
671
+ if (hasContents && !recursive) {
672
+ throw mediaFolderHasContents(
673
+ (id as unknown) as string,
674
+ subfolders.length,
675
+ assets.length,
676
+ );
677
+ }
678
+
679
+ // If recursive, delete all contents first
680
+ if (recursive && hasContents) {
681
+ await deleteContentsRecursively(ctx, id, hardDelete);
682
+ }
683
+
684
+ if (hardDelete) {
685
+ // Permanently delete the folder
686
+ await ctx.db.delete(id);
687
+
688
+ return {
689
+ ...folder,
690
+ deletedAt: Date.now(),
691
+ };
692
+ } else {
693
+ // Soft delete
694
+ const now = Date.now();
695
+ await ctx.db.patch(id, {
696
+ deletedAt: now,
697
+ });
698
+
699
+ return {
700
+ ...folder,
701
+ deletedAt: now,
702
+ };
703
+ }
704
+ },
705
+ });
706
+
707
+ /**
708
+ * Recursively deletes all contents of a folder.
709
+ */
710
+ async function deleteContentsRecursively(
711
+ ctx: MutationCtx,
712
+ folderId: Id<"mediaItems">,
713
+ hardDelete: boolean,
714
+ ): Promise<void> {
715
+ // Get all subfolders
716
+ const subfolders = await ctx.db
717
+ .query("mediaItems")
718
+ .withIndex("by_kind_and_parent", (q) =>
719
+ q.eq("kind", "folder").eq("parentId", folderId),
720
+ )
721
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
722
+ .collect();
723
+
724
+ // Recursively delete subfolders first
725
+ for (const subfolder of subfolders) {
726
+ await deleteContentsRecursively(ctx, subfolder._id, hardDelete);
727
+
728
+ if (hardDelete) {
729
+ await ctx.db.delete(subfolder._id);
730
+ } else {
731
+ await ctx.db.patch(subfolder._id, { deletedAt: Date.now() });
732
+ }
733
+ }
734
+
735
+ // Delete/soft-delete all assets in this folder
736
+ const assets = await ctx.db
737
+ .query("mediaItems")
738
+ .withIndex("by_kind_and_parent", (q) =>
739
+ q.eq("kind", "asset").eq("parentId", folderId),
740
+ )
741
+ .filter((q) => q.eq(q.field("deletedAt"), undefined))
742
+ .collect();
743
+
744
+ for (const item of assets) {
745
+ // Type guard for asset (we already filtered by kind="asset" in the query)
746
+ if (item.kind !== "asset") continue;
747
+ const asset = item;
748
+
749
+ if (hardDelete) {
750
+ // For hard delete, also delete the storage file
751
+ try {
752
+ await ctx.storage.delete(asset.storageId);
753
+ } catch (error) {
754
+ console.warn(
755
+ `Could not delete storage file for asset ${asset._id}:`,
756
+ error instanceof Error ? error.message : error,
757
+ );
758
+ }
759
+ await ctx.db.delete(asset._id);
760
+ } else {
761
+ await ctx.db.patch(asset._id, { deletedAt: Date.now() });
762
+ }
763
+ }
764
+ }
765
+
766
+ // =============================================================================
767
+ // Restore Media Folder Mutation
768
+ // =============================================================================
769
+
770
+ /**
771
+ * Validator for restore folder arguments.
772
+ */
773
+ export const restoreMediaFolderArgs = {
774
+ id: v.id("mediaItems"),
775
+ restoredBy: v.optional(v.string()),
776
+ recursive: v.optional(v.boolean()),
777
+ };
778
+
779
+ /**
780
+ * Mutation to restore a soft-deleted media folder.
781
+ *
782
+ * Removes the deletedAt timestamp from the folder.
783
+ * Optionally restores all contents recursively.
784
+ *
785
+ * @param id - The folder ID to restore
786
+ * @param restoredBy - Optional user ID for audit trail
787
+ * @param recursive - If true, restores folder and all contents
788
+ *
789
+ * @returns The restored media folder document
790
+ *
791
+ * @throws Error if the folder does not exist
792
+ * @throws Error if the folder is not deleted
793
+ * @throws Error if the parent folder is still deleted
794
+ *
795
+ * @example
796
+ * ```typescript
797
+ * const restored = await ctx.runMutation(api.mediaFolderMutations.restoreMediaFolder, {
798
+ * id: folderId,
799
+ * restoredBy: currentUserId,
800
+ * recursive: true,
801
+ * });
802
+ * ```
803
+ */
804
+ export const restoreMediaFolder = mutation({
805
+ args: {
806
+ ...restoreMediaFolderArgs,
807
+ /** Optional auth context for mutation-level authorization */
808
+ _auth: v.optional(mutationAuthContext),
809
+ },
810
+ returns: mediaItemDoc,
811
+ handler: async (ctx, args) => {
812
+ const { id,
813
+ // restoredBy,
814
+ recursive = false, _auth } = args;
815
+
816
+ // Authorization check - use update permission for restore
817
+ requireMutationAuth(_auth, "mediaItems", "update");
818
+
819
+ const folder = await ctx.db.get(id);
820
+
821
+ if (!folder) {
822
+ throw mediaFolderNotFound((id as unknown) as string);
823
+ }
824
+
825
+ if (!isDeleted(folder)) {
826
+ throw mediaFolderNotDeleted((id as unknown) as string);
827
+ }
828
+
829
+ // Check that parent folder is not deleted (if it exists)
830
+ if (folder.parentId) {
831
+ const parentFolder = await ctx.db.get(folder.parentId);
832
+ if (parentFolder && isDeleted(parentFolder)) {
833
+ throw mediaFolderParentDeleted(
834
+ (id as unknown) as string,
835
+ (folder.parentId as unknown) as string,
836
+ );
837
+ }
838
+ }
839
+
840
+ // Restore the folder
841
+ await ctx.db.patch(id, {
842
+ deletedAt: undefined,
843
+ });
844
+
845
+ // If recursive, restore all contents
846
+ if (recursive) {
847
+ await restoreContentsRecursively(ctx, id);
848
+ }
849
+
850
+ // Retrieve and return the restored folder
851
+ const restoredFolder = await ctx.db.get(id);
852
+ if (!restoredFolder) {
853
+ throw internalError("Failed to restore media folder");
854
+ }
855
+
856
+ return restoredFolder;
857
+ },
858
+ });
859
+
860
+ /**
861
+ * Recursively restores all contents of a folder.
862
+ */
863
+ async function restoreContentsRecursively(
864
+ ctx: MutationCtx,
865
+ folderId: Id<"mediaItems">,
866
+ ): Promise<void> {
867
+ // Get all soft-deleted subfolders
868
+ const subfolders = await ctx.db
869
+ .query("mediaItems")
870
+ .withIndex("by_kind_and_parent", (q) =>
871
+ q.eq("kind", "folder").eq("parentId", folderId),
872
+ )
873
+ .filter((q) => q.neq(q.field("deletedAt"), undefined))
874
+ .collect();
875
+
876
+ for (const subfolder of subfolders) {
877
+ await ctx.db.patch(subfolder._id, { deletedAt: undefined });
878
+ await restoreContentsRecursively(ctx, subfolder._id);
879
+ }
880
+
881
+ // Restore all soft-deleted assets in this folder
882
+ const assets = await ctx.db
883
+ .query("mediaItems")
884
+ .withIndex("by_kind_and_parent", (q) =>
885
+ q.eq("kind", "asset").eq("parentId", folderId),
886
+ )
887
+ .filter((q) => q.neq(q.field("deletedAt"), undefined))
888
+ .collect();
889
+
890
+ for (const asset of assets) {
891
+ await ctx.db.patch(asset._id, { deletedAt: undefined });
892
+ }
893
+ }
894
+
895
+ // =============================================================================
896
+ // Query Functions
897
+ // =============================================================================
898
+
899
+ /**
900
+ * Query to get a media folder by ID.
901
+ *
902
+ * @param id - The folder ID
903
+ * @param includeDeleted - If true, returns soft-deleted folders
904
+ *
905
+ * @returns The folder document or null if not found
906
+ */
907
+ export const getMediaFolder = query({
908
+ args: {
909
+ id: v.id("mediaItems"),
910
+ includeDeleted: v.optional(v.boolean()),
911
+ },
912
+ returns: v.union(mediaItemDoc, v.null()),
913
+ handler: async (ctx, args) => {
914
+ const { id, includeDeleted = false } = args;
915
+
916
+ const item = await ctx.db.get(id);
917
+
918
+ // Must be a folder
919
+ if (!item || item.kind !== "folder") {
920
+ return null;
921
+ }
922
+
923
+ if (!includeDeleted && isDeleted(item)) {
924
+ return null;
925
+ }
926
+
927
+ return item;
928
+ },
929
+ });
930
+
931
+ /**
932
+ * Query to list folders in a parent folder.
933
+ *
934
+ * @param parentId - The parent folder ID (undefined for root folders)
935
+ * @param includeDeleted - If true, includes soft-deleted folders
936
+ * @param deletedOnly - If true, shows only soft-deleted folders (ignores parentId)
937
+ *
938
+ * @returns Array of folder documents sorted by sortOrder, then name
939
+ */
940
+ export const listMediaFolders = query({
941
+ args: {
942
+ parentId: v.optional(v.id("mediaItems")),
943
+ includeDeleted: v.optional(v.boolean()),
944
+ deletedOnly: v.optional(v.boolean()),
945
+ },
946
+ returns: v.array(mediaItemDoc),
947
+ handler: async (ctx, args) => {
948
+ const { parentId, includeDeleted = false, deletedOnly = false } = args;
949
+
950
+ // When viewing trash (deletedOnly), show all deleted folders regardless of parent
951
+ let query = deletedOnly
952
+ ? ctx.db
953
+ .query("mediaItems")
954
+ .withIndex("by_kind", (q) => q.eq("kind", "folder"))
955
+ : ctx.db
956
+ .query("mediaItems")
957
+ .withIndex("by_kind_and_parent", (q) =>
958
+ q.eq("kind", "folder").eq("parentId", parentId),
959
+ );
960
+
961
+ if (deletedOnly) {
962
+ query = query.filter((q) => q.neq(q.field("deletedAt"), undefined));
963
+ } else if (!includeDeleted) {
964
+ query = query.filter((q) => q.eq(q.field("deletedAt"), undefined));
965
+ }
966
+
967
+ const folders = await query.collect();
968
+
969
+ // Sort by sortOrder (nulls last), then by name
970
+ // Note: sortOrder is only defined on folders, so these are safe after kind filter
971
+ folders.sort((a, b) => {
972
+ const aOrder = a.kind === "folder" ? a.sortOrder : undefined;
973
+ const bOrder = b.kind === "folder" ? b.sortOrder : undefined;
974
+ if (aOrder !== undefined && bOrder !== undefined) {
975
+ return aOrder - bOrder;
976
+ }
977
+ if (aOrder !== undefined) return -1;
978
+ if (bOrder !== undefined) return 1;
979
+ return a.name.localeCompare(b.name);
980
+ });
981
+
982
+ return folders;
983
+ },
984
+ });
985
+
986
+ /**
987
+ * Query to get a folder by its path.
988
+ *
989
+ * @param path - The full folder path (e.g., "/Images/Blog")
990
+ * @param includeDeleted - If true, returns soft-deleted folders
991
+ *
992
+ * @returns The folder document or null if not found
993
+ */
994
+ export const getMediaFolderByPath = query({
995
+ args: {
996
+ path: v.string(),
997
+ includeDeleted: v.optional(v.boolean()),
998
+ },
999
+ returns: v.union(mediaItemDoc, v.null()),
1000
+ handler: async (ctx, args) => {
1001
+ const { path, includeDeleted = false } = args;
1002
+
1003
+ let query = ctx.db
1004
+ .query("mediaItems")
1005
+ .withIndex("by_path", (q) => q.eq("path", path))
1006
+ .filter((q) => q.eq(q.field("kind"), "folder"));
1007
+
1008
+ if (!includeDeleted) {
1009
+ query = query.filter((q) => q.eq(q.field("deletedAt"), undefined));
1010
+ }
1011
+
1012
+ return await query.first();
1013
+ },
1014
+ });
1015
+
1016
+ /**
1017
+ * Query to get the folder tree (all folders as a flat list with hierarchy info).
1018
+ *
1019
+ * @param includeDeleted - If true, includes soft-deleted folders
1020
+ *
1021
+ * @returns Array of all folders
1022
+ */
1023
+ export const getFolderTree = query({
1024
+ args: {
1025
+ includeDeleted: v.optional(v.boolean()),
1026
+ },
1027
+ returns: v.array(mediaItemDoc),
1028
+ handler: async (ctx, args) => {
1029
+ const { includeDeleted = false } = args;
1030
+
1031
+ let query = ctx.db
1032
+ .query("mediaItems")
1033
+ .withIndex("by_kind", (q) => q.eq("kind", "folder"));
1034
+
1035
+ if (!includeDeleted) {
1036
+ query = query.filter((q) => q.eq(q.field("deletedAt"), undefined));
1037
+ }
1038
+
1039
+ const folders = await query.collect();
1040
+
1041
+ // Sort by path for hierarchical display
1042
+ folders.sort((a, b) => a.path.localeCompare(b.path));
1043
+
1044
+ return folders;
1045
+ },
1046
+ });