convex-cms 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-dist/nitro.json +15 -0
- package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
- package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
- package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
- package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
- package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
- package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
- package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
- package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
- package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
- package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
- package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
- package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
- package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
- package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
- package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
- package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
- package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
- package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
- package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
- package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
- package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
- package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
- package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
- package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
- package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
- package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
- package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
- package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
- package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
- package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
- package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
- package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
- package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
- package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
- package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
- package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
- package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
- package/admin-dist/public/favicon.ico +0 -0
- package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
- package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
- package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
- package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
- package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
- package/admin-dist/server/_libs/clsx.mjs +16 -0
- package/admin-dist/server/_libs/cmdk.mjs +315 -0
- package/admin-dist/server/_libs/convex.mjs +4841 -0
- package/admin-dist/server/_libs/cookie-es.mjs +58 -0
- package/admin-dist/server/_libs/croner.mjs +1 -0
- package/admin-dist/server/_libs/crossws.mjs +1 -0
- package/admin-dist/server/_libs/date-fns.mjs +1716 -0
- package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
- package/admin-dist/server/_libs/get-nonce.mjs +9 -0
- package/admin-dist/server/_libs/h3-v2.mjs +277 -0
- package/admin-dist/server/_libs/h3.mjs +401 -0
- package/admin-dist/server/_libs/hookable.mjs +1 -0
- package/admin-dist/server/_libs/isbot.mjs +20 -0
- package/admin-dist/server/_libs/lucide-react.mjs +850 -0
- package/admin-dist/server/_libs/ohash.mjs +1 -0
- package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
- package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
- package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
- package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
- package/admin-dist/server/_libs/rou3.mjs +8 -0
- package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
- package/admin-dist/server/_libs/seroval.mjs +1765 -0
- package/admin-dist/server/_libs/srvx.mjs +719 -0
- package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
- package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
- package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
- package/admin-dist/server/_libs/tslib.mjs +39 -0
- package/admin-dist/server/_libs/ufo.mjs +54 -0
- package/admin-dist/server/_libs/unctx.mjs +1 -0
- package/admin-dist/server/_libs/unstorage.mjs +1 -0
- package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
- package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
- package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
- package/admin-dist/server/_libs/zod.mjs +4223 -0
- package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
- package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
- package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
- package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
- package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
- package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
- package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
- package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
- package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
- package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
- package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
- package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
- package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
- package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
- package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
- package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
- package/admin-dist/server/_ssr/index.mjs +1275 -0
- package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
- package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
- package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
- package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
- package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
- package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
- package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
- package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
- package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
- package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
- package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
- package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
- package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
- package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
- package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
- package/admin-dist/server/favicon.ico +0 -0
- package/admin-dist/server/index.mjs +627 -0
- package/dist/cli/index.js +0 -0
- package/dist/client/admin-config.d.ts +0 -1
- package/dist/client/admin-config.d.ts.map +1 -1
- package/dist/client/admin-config.js +0 -1
- package/dist/client/admin-config.js.map +1 -1
- package/dist/client/adminApi.d.ts.map +1 -1
- package/dist/client/agentTools.d.ts +1237 -135
- package/dist/client/agentTools.d.ts.map +1 -1
- package/dist/client/agentTools.js +33 -9
- package/dist/client/agentTools.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/mediaAssets.d.ts +35 -0
- package/dist/component/mediaAssets.d.ts.map +1 -1
- package/dist/component/mediaAssets.js +81 -0
- package/dist/component/mediaAssets.js.map +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +2 -1
- package/dist/test.js.map +1 -1
- package/package.json +9 -5
- package/dist/component/auditLog.d.ts +0 -410
- package/dist/component/auditLog.d.ts.map +0 -1
- package/dist/component/auditLog.js +0 -607
- package/dist/component/auditLog.js.map +0 -1
- package/dist/component/types.d.ts +0 -4
- package/dist/component/types.d.ts.map +0 -1
- package/dist/component/types.js +0 -2
- package/dist/component/types.js.map +0 -1
- package/src/cli/commands/admin.ts +0 -104
- package/src/cli/index.ts +0 -21
- package/src/cli/utils/detectConvexUrl.ts +0 -54
- package/src/cli/utils/openBrowser.ts +0 -16
- package/src/client/admin-config.ts +0 -138
- package/src/client/adminApi.ts +0 -942
- package/src/client/agentTools.ts +0 -1311
- package/src/client/argTypes.ts +0 -316
- package/src/client/field-types.ts +0 -187
- package/src/client/index.ts +0 -1301
- package/src/client/queryBuilder.ts +0 -1100
- package/src/client/schema/codegen.ts +0 -500
- package/src/client/schema/defineContentType.ts +0 -501
- package/src/client/schema/index.ts +0 -169
- package/src/client/schema/schemaDrift.ts +0 -574
- package/src/client/schema/typedClient.ts +0 -688
- package/src/client/schema/types.ts +0 -666
- package/src/client/types.ts +0 -723
- package/src/client/workflows.ts +0 -141
- package/src/client/wrapper.ts +0 -4304
- package/src/component/_generated/api.ts +0 -140
- package/src/component/_generated/component.ts +0 -5029
- package/src/component/_generated/dataModel.ts +0 -60
- package/src/component/_generated/server.ts +0 -156
- package/src/component/authorization.ts +0 -647
- package/src/component/authorizationHooks.ts +0 -668
- package/src/component/bulkOperations.ts +0 -687
- package/src/component/contentEntries.ts +0 -1976
- package/src/component/contentEntryMutations.ts +0 -1223
- package/src/component/contentEntryValidation.ts +0 -707
- package/src/component/contentLock.ts +0 -550
- package/src/component/contentTypeMigration.ts +0 -1064
- package/src/component/contentTypeMutations.ts +0 -969
- package/src/component/contentTypes.ts +0 -346
- package/src/component/convex.config.ts +0 -44
- package/src/component/documentTypes.ts +0 -240
- package/src/component/eventEmitter.ts +0 -485
- package/src/component/exportImport.ts +0 -1169
- package/src/component/index.ts +0 -491
- package/src/component/lib/deepReferenceResolver.ts +0 -999
- package/src/component/lib/errors.ts +0 -816
- package/src/component/lib/index.ts +0 -145
- package/src/component/lib/mediaReferenceResolver.ts +0 -495
- package/src/component/lib/metadataExtractor.ts +0 -792
- package/src/component/lib/mutationAuth.ts +0 -199
- package/src/component/lib/queries.ts +0 -79
- package/src/component/lib/ragContentChunker.ts +0 -1371
- package/src/component/lib/referenceResolver.ts +0 -430
- package/src/component/lib/slugGenerator.ts +0 -262
- package/src/component/lib/slugUniqueness.ts +0 -333
- package/src/component/lib/softDelete.ts +0 -44
- package/src/component/localeFallbackChain.ts +0 -673
- package/src/component/localeFields.ts +0 -896
- package/src/component/mediaAssetMutations.ts +0 -725
- package/src/component/mediaAssets.ts +0 -932
- package/src/component/mediaFolderMutations.ts +0 -1046
- package/src/component/mediaUploadMutations.ts +0 -224
- package/src/component/mediaVariantMutations.ts +0 -900
- package/src/component/mediaVariants.ts +0 -793
- package/src/component/ragContentIndexer.ts +0 -1067
- package/src/component/rateLimitHooks.ts +0 -572
- package/src/component/roles.ts +0 -1360
- package/src/component/scheduledPublish.ts +0 -358
- package/src/component/schema.ts +0 -617
- package/src/component/taxonomies.ts +0 -949
- package/src/component/taxonomyMutations.ts +0 -1210
- package/src/component/trash.ts +0 -724
- package/src/component/userContext.ts +0 -898
- package/src/component/validation.ts +0 -1388
- package/src/component/validators.ts +0 -949
- package/src/component/versionMutations.ts +0 -392
- package/src/component/webhookTrigger.ts +0 -1922
- package/src/react/index.ts +0 -898
- package/src/test.ts +0 -1580
|
@@ -1,1046 +0,0 @@
|
|
|
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
|
-
});
|