create-vexcms 0.0.19 → 0.1.0-alpha.2

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 (154) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +93 -83
  3. package/dist/index.js +246 -81
  4. package/dist/index.mjs +30976 -0
  5. package/package.json +35 -15
  6. package/templates/base-nextjs/README.md +53 -205
  7. package/templates/base-nextjs/_env.example +7 -9
  8. package/templates/base-nextjs/_gitignore +41 -1
  9. package/templates/base-nextjs/convex/_generated/api.d.ts +6 -62
  10. package/templates/base-nextjs/convex/_generated/server.d.ts +19 -0
  11. package/templates/base-nextjs/convex/_generated/server.js +8 -0
  12. package/templates/base-nextjs/convex/auth/api.ts +49 -3
  13. package/templates/base-nextjs/convex/auth/db.ts +42 -300
  14. package/templates/base-nextjs/convex/auth/index.ts +11 -7
  15. package/templates/base-nextjs/convex/auth/options.ts +5 -29
  16. package/templates/base-nextjs/convex/auth/plugins/index.ts +15 -5
  17. package/templates/base-nextjs/convex/auth/sessions.ts +9 -15
  18. package/templates/base-nextjs/convex/http.ts +18 -3
  19. package/templates/base-nextjs/convex/schema.ts +104 -2
  20. package/templates/base-nextjs/convex/vex/firstUser.ts +10 -84
  21. package/templates/base-nextjs/convex/vex/globals.ts +19 -82
  22. package/templates/base-nextjs/convex/vex/media.ts +19 -246
  23. package/templates/base-nextjs/convex/vex.schema.ts +138 -0
  24. package/templates/base-nextjs/convex/vex.ts +19 -0
  25. package/templates/base-nextjs/convex.json +6 -0
  26. package/templates/base-nextjs/eslint.config.mjs +1 -21
  27. package/templates/base-nextjs/next.config.ts +10 -0
  28. package/templates/base-nextjs/package.json +47 -48
  29. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +17 -5
  30. package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +2 -4
  31. package/templates/base-nextjs/src/app/(frontend)/layout.tsx +12 -2
  32. package/templates/base-nextjs/src/app/(frontend)/page.tsx +2 -113
  33. package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +40 -0
  34. package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +22 -0
  35. package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +19 -0
  36. package/templates/base-nextjs/src/app/globals.css +240 -104
  37. package/templates/base-nextjs/src/app/layout.tsx +23 -13
  38. package/templates/base-nextjs/src/app/unauthorized/page.tsx +24 -0
  39. package/templates/base-nextjs/src/auth/access.ts +46 -0
  40. package/templates/base-nextjs/src/auth/client.tsx +17 -5
  41. package/templates/base-nextjs/src/auth/hasPermission.ts +39 -0
  42. package/templates/base-nextjs/src/auth/serverUtils.ts +15 -22
  43. package/templates/base-nextjs/src/components/WelcomePage.tsx +116 -0
  44. package/templates/base-nextjs/src/components/component-example.tsx +56 -464
  45. package/templates/base-nextjs/src/components/providers/auth.tsx +13 -0
  46. package/templates/base-nextjs/src/components/providers/client.tsx +6 -1
  47. package/templates/base-nextjs/src/components/providers/convex.tsx +53 -21
  48. package/templates/base-nextjs/src/components/providers/server.tsx +10 -7
  49. package/templates/{marketing-site → base-nextjs}/src/components/ui/accordion.tsx +30 -32
  50. package/templates/base-nextjs/src/components/ui/badge.tsx +10 -13
  51. package/templates/base-nextjs/src/components/ui/input.tsx +35 -5
  52. package/templates/base-nextjs/src/context/AuthContext.tsx +29 -0
  53. package/templates/base-nextjs/src/db/constants/auth.ts +0 -1
  54. package/templates/base-nextjs/src/db/constants/index.ts +22 -35
  55. package/templates/base-nextjs/src/lib/utils.ts +0 -12
  56. package/templates/base-nextjs/src/proxy.ts +98 -11
  57. package/templates/base-nextjs/src/vex.config.ts +35 -0
  58. package/templates/base-nextjs/src/vex.types.ts +293 -0
  59. package/templates/base-nextjs/src/vexcms/collections/images.ts +15 -0
  60. package/templates/base-nextjs/src/vexcms/collections/index.ts +2 -1
  61. package/templates/base-nextjs/src/vexcms/collections/users.ts +7 -35
  62. package/templates/base-nextjs/tsconfig.json +3 -0
  63. package/templates/marketing-site/convex/_generated/api.d.ts +81 -0
  64. package/templates/marketing-site/convex/_generated/api.js +23 -0
  65. package/templates/marketing-site/convex/_generated/dataModel.d.ts +60 -0
  66. package/templates/marketing-site/convex/_generated/server.d.ts +162 -0
  67. package/templates/marketing-site/convex/_generated/server.js +101 -0
  68. package/templates/marketing-site/convex/footers.ts +11 -7
  69. package/templates/marketing-site/convex/headers.ts +15 -5
  70. package/templates/marketing-site/convex/pages.ts +20 -35
  71. package/templates/marketing-site/convex/schema.ts +119 -0
  72. package/templates/marketing-site/convex/seed.ts +582 -35
  73. package/templates/marketing-site/convex/siteSettings.ts +19 -5
  74. package/templates/marketing-site/convex/theme.ts +55 -61
  75. package/templates/marketing-site/convex/vex.schema.ts +355 -0
  76. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +27 -160
  77. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +6 -9
  78. package/templates/marketing-site/src/app/(frontend)/layout.tsx +4 -2
  79. package/templates/marketing-site/src/app/(frontend)/page.tsx +2 -5
  80. package/templates/marketing-site/src/components/SiteFooter.tsx +6 -3
  81. package/templates/marketing-site/src/components/SiteHeader.tsx +6 -3
  82. package/templates/marketing-site/src/components/ThemeLive.tsx +59 -0
  83. package/templates/marketing-site/src/components/ThemeStyle.tsx +45 -89
  84. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +2 -2
  85. package/templates/marketing-site/src/lib/metadata.ts +51 -72
  86. package/templates/marketing-site/src/vex.config.ts +35 -0
  87. package/templates/marketing-site/src/vex.types.ts +634 -0
  88. package/templates/marketing-site/src/vexcms/api.ts +23 -0
  89. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -3
  90. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +2 -2
  91. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -4
  92. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +2 -2
  93. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +4 -8
  94. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +2 -2
  95. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +7 -11
  96. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +1 -1
  97. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +9 -9
  98. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +4 -5
  99. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +3 -4
  100. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +10 -19
  101. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +4 -9
  102. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +3 -6
  103. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +28 -68
  104. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +13 -43
  105. package/templates/marketing-site/src/vexcms/blocks/config.ts +7 -22
  106. package/templates/marketing-site/src/vexcms/blocks/constants.ts +2 -3
  107. package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -8
  108. package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -1
  109. package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -1
  110. package/templates/marketing-site/src/vexcms/collections/index.ts +2 -1
  111. package/templates/marketing-site/src/vexcms/collections/pages.ts +12 -21
  112. package/templates/marketing-site/src/vexcms/collections/themeColors.ts +118 -0
  113. package/templates/marketing-site/src/vexcms/collections/themes.ts +29 -111
  114. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -1
  115. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +43 -27
  116. package/templates/base-nextjs/convex/auth/adapter/index.ts +0 -230
  117. package/templates/base-nextjs/convex/auth/adapter/utils.ts +0 -547
  118. package/templates/base-nextjs/convex/auth/config.ts +0 -10
  119. package/templates/base-nextjs/convex/vex/auth.ts +0 -38
  120. package/templates/base-nextjs/convex/vex/collections.ts +0 -321
  121. package/templates/base-nextjs/convex/vex/helpers.ts +0 -33
  122. package/templates/base-nextjs/convex/vex/impersonation.ts +0 -51
  123. package/templates/base-nextjs/convex/vex/migrate.ts +0 -74
  124. package/templates/base-nextjs/convex/vex/model/collections.ts +0 -196
  125. package/templates/base-nextjs/convex/vex/model/media.ts +0 -87
  126. package/templates/base-nextjs/convex/vex/model/versions.ts +0 -254
  127. package/templates/base-nextjs/convex/vex/previewSnapshot.ts +0 -53
  128. package/templates/base-nextjs/convex/vex/versions.ts +0 -648
  129. package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +0 -33
  130. package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +0 -38
  131. package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +0 -101
  132. package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +0 -15
  133. package/templates/base-nextjs/src/app/admin/layout.tsx +0 -51
  134. package/templates/base-nextjs/src/app/favicon.ico +0 -0
  135. package/templates/base-nextjs/src/components/providers/theme.tsx +0 -11
  136. package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +0 -67
  137. package/templates/base-nextjs/src/db/types.ts +0 -6
  138. package/templates/base-nextjs/src/vexcms/access.ts +0 -44
  139. package/templates/base-nextjs/src/vexcms/auth.ts +0 -4
  140. package/templates/base-nextjs/src/vexcms/collections/media.ts +0 -15
  141. package/templates/base-nextjs/vex.config.ts +0 -25
  142. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  143. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +0 -55
  144. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +0 -27
  145. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +0 -18
  146. package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +0 -71
  147. package/templates/marketing-site/src/components/ThemeInjector.tsx +0 -128
  148. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +0 -134
  149. package/templates/marketing-site/src/components/admin/ThemeImport.tsx +0 -174
  150. package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +0 -33
  151. package/templates/marketing-site/src/db/constants/index.ts +0 -52
  152. package/templates/marketing-site/src/lib/colorConvert.ts +0 -171
  153. package/templates/marketing-site/src/vexcms/access.ts +0 -36
  154. package/templates/marketing-site/vex.config.ts +0 -32
@@ -6,27 +6,23 @@ import { TABLE_SLUG_USERS } from "~/db/constants"
6
6
  import { USER_ROLES } from "~/db/constants/auth"
7
7
 
8
8
  /**
9
- * Check if the admin panel has been bootstrapped (at least one admin exists).
10
- * Used by the landing page to determine whether to show "Sign Up" or "Sign In".
9
+ * Check whether the admin panel has been bootstrapped (at least one admin
10
+ * exists). Used by `WelcomePage` to decide between "Sign Up" and "Sign In".
11
11
  */
12
12
  export const isBootstrapped = query({
13
13
  args: {},
14
14
  handler: async (ctx) => {
15
15
  const allUsers = await ctx.db.query(TABLE_SLUG_USERS).collect()
16
-
17
- return allUsers.some((user) => {
18
- const roles = user.role ?? []
19
- return roles.includes(USER_ROLES.admin)
20
- })
16
+ return allUsers.some((user) => user.roles?.includes(USER_ROLES.admin))
21
17
  },
22
18
  })
23
19
 
24
20
  /**
25
- * Promote the current user to admin if no admin exists yet.
26
- * Called after the first user signs up.
21
+ * Promote the current user to admin if no admin exists yet. Called after the
22
+ * first user signs up.
27
23
  *
28
- * Convex mutations are serialized, so two simultaneous signups
29
- * cannot both become admin — the second will see the first's promotion.
24
+ * Convex mutations are serialized, so two simultaneous signups cannot both
25
+ * become admin — the second observes the first's promotion and no-ops.
30
26
  */
31
27
  export const promoteFirstAdmin = mutation({
32
28
  args: {},
@@ -36,7 +32,6 @@ export const promoteFirstAdmin = mutation({
36
32
  throw new ConvexError("Not authenticated")
37
33
  }
38
34
 
39
- // Find the current user by email
40
35
  const currentUser = await ctx.db
41
36
  .query(TABLE_SLUG_USERS)
42
37
  .withIndex("by_email", (q) => q.eq("email", identity.email!))
@@ -46,89 +41,20 @@ export const promoteFirstAdmin = mutation({
46
41
  throw new ConvexError("User not found")
47
42
  }
48
43
 
49
- // Check if any user already has admin role
50
44
  const allUsers = await ctx.db.query(TABLE_SLUG_USERS).collect()
51
- const hasAdmin = allUsers.some((user) => {
52
- const roles = user.role ?? []
53
- return roles.includes(USER_ROLES.admin)
54
- })
45
+ const hasAdmin = allUsers.some((user) => user.roles?.includes(USER_ROLES.admin))
55
46
 
56
47
  if (hasAdmin) {
57
48
  return { promoted: false }
58
49
  }
59
50
 
60
- // Promote this user to admin
61
- const currentRoles = currentUser.role ?? []
51
+ const currentRoles = currentUser.roles ?? []
62
52
  if (!currentRoles.includes(USER_ROLES.admin)) {
63
53
  await ctx.db.patch(currentUser._id, {
64
- role: [...currentRoles, USER_ROLES.admin],
54
+ roles: [...currentRoles, USER_ROLES.admin],
65
55
  })
66
56
  }
67
57
 
68
58
  return { promoted: true }
69
59
  },
70
60
  })
71
-
72
- /**
73
- * Get the onboarding completion status for a user.
74
- */
75
- export const getOnboardingStatus = query({
76
- args: {},
77
- handler: async (ctx) => {
78
- const identity = await ctx.auth.getUserIdentity()
79
- if (!identity?.email) return { complete: false }
80
-
81
- const user = await ctx.db
82
- .query(TABLE_SLUG_USERS)
83
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
84
- .first()
85
-
86
- if (!user) return { complete: false }
87
-
88
- return { complete: !!(user as any).vex_onboarding_complete }
89
- },
90
- })
91
-
92
- /**
93
- * Mark the onboarding tour as complete for the current user.
94
- */
95
- export const completeOnboarding = mutation({
96
- args: {},
97
- handler: async (ctx) => {
98
- const identity = await ctx.auth.getUserIdentity()
99
- if (!identity?.email) return
100
-
101
- const user = await ctx.db
102
- .query(TABLE_SLUG_USERS)
103
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
104
- .first()
105
-
106
- if (!user) return
107
-
108
- await ctx.db.patch(user._id, {
109
- vex_onboarding_complete: true,
110
- } as any)
111
- },
112
- })
113
-
114
- /**
115
- * Reset the onboarding tour for the current user (restart tour).
116
- */
117
- export const resetOnboarding = mutation({
118
- args: {},
119
- handler: async (ctx) => {
120
- const identity = await ctx.auth.getUserIdentity()
121
- if (!identity?.email) return
122
-
123
- const user = await ctx.db
124
- .query(TABLE_SLUG_USERS)
125
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
126
- .first()
127
-
128
- if (!user) return
129
-
130
- await ctx.db.patch(user._id, {
131
- vex_onboarding_complete: false,
132
- } as any)
133
- },
134
- })
@@ -1,82 +1,19 @@
1
- import type { DataModel } from "@convex/_generated/dataModel"
2
- import type { TableNamesInDataModel } from "convex/server"
3
-
4
- import { query } from "@convex/_generated/server"
5
- import { getPreviewSnapshot } from "@vexcms/core"
6
- import { v } from "convex/values"
7
-
8
- import config from "../../vex.config"
9
- import * as Versions from "./model/versions"
10
-
11
- /**
12
- * Get the single document for a global, with draft awareness.
13
- *
14
- * - No _vexDrafts or _vexDrafts: false → returns the published document
15
- * - _vexDrafts: true → returns the latest version (draft or published)
16
- * - _vexDrafts: "snapshot" → overlays the preview snapshot on the document
17
- */
18
- export const get = query({
19
- args: {
20
- globalSlug: v.string(),
21
- _vexDrafts: v.optional(v.union(v.literal("snapshot"), v.boolean())),
22
- },
23
- handler: async (ctx, { globalSlug, _vexDrafts }) => {
24
- const doc = await ctx.db
25
- .query(globalSlug as TableNamesInDataModel<DataModel>)
26
- .first()
27
-
28
- if (!doc) return null
29
-
30
- const documentId = doc._id as string
31
-
32
- // Check if this global has versioning enabled
33
- const globalDef = config.globals?.find((g) => g.slug === globalSlug)
34
- const isVersioned = !!globalDef?.versions?.drafts
35
-
36
- // If draft mode is "snapshot", overlay the preview snapshot
37
- if (_vexDrafts === "snapshot") {
38
- const snapshot = await getPreviewSnapshot<DataModel>({
39
- ctx,
40
- collection: globalSlug,
41
- documentId,
42
- })
43
-
44
- if (snapshot) {
45
- return {
46
- ...doc,
47
- ...(snapshot as Record<string, unknown>),
48
- _id: doc._id,
49
- _creationTime: doc._creationTime,
50
- }
51
- }
52
- }
53
-
54
- // If versioned and draft-aware, return the latest version's snapshot
55
- if (isVersioned && _vexDrafts !== false) {
56
- const latestVersion = await Versions.getLatestVersion<DataModel>({
57
- ctx,
58
- collection: globalSlug,
59
- documentId,
60
- })
61
-
62
- if (latestVersion) {
63
- return {
64
- _id: doc._id,
65
- ...(latestVersion.snapshot as Record<string, unknown>),
66
- vex_status: (latestVersion as any).status ?? (doc as any).vex_status ?? "draft",
67
- vex_version: latestVersion.version,
68
- vex_publishedAt: (doc as any).vex_publishedAt,
69
- _creationTime: doc._creationTime,
70
- }
71
- }
72
-
73
- // No versions yet — treat as published
74
- const raw = doc as Record<string, unknown>
75
- if (!raw.vex_status) {
76
- raw.vex_status = "published"
77
- }
78
- }
79
-
80
- return doc
81
- },
82
- })
1
+ import { createGetAuth } from "@vexcms/better-auth";
2
+ import { globalsApi } from "@vexcms/core/server";
3
+
4
+ import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
5
+ import config from "~/vex.config";
6
+
7
+ import { mutation, query } from "../_generated/server";
8
+
9
+ export const { get, find, upsert } = globalsApi({
10
+ config,
11
+ query,
12
+ mutation,
13
+ getAuth: createGetAuth({
14
+ orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
15
+ userCollectionSlug: TABLE_SLUG_USERS,
16
+ sessionCollectionSlug: TABLE_SLUG_SESSIONS,
17
+ resolveOrgs: true,
18
+ }),
19
+ });
@@ -1,246 +1,19 @@
1
- import type { DataModel } from "@convex/_generated/dataModel"
2
- import type { GenericQueryCtx, TableNamesInDataModel } from "convex/server"
3
-
4
- import { ConvexError } from "convex/values"
5
- import { mutation, query, action } from "@convex/_generated/server"
6
- import { paginationOptsValidator } from "convex/server"
7
- import { v } from "convex/values"
8
-
9
- import { findCollectionBySlug, hasPermission } from "@vexcms/core"
10
- import { TABLE_SLUG_USERS } from "~/db/constants"
11
- import config from "../../vex.config"
12
- import { access } from "../../src/vexcms/access"
13
-
14
- import * as Media from "./model/media"
15
-
16
- async function getUser(ctx: GenericQueryCtx<DataModel>) {
17
- const identity = await ctx.auth.getUserIdentity()
18
- if (!identity?.email) return null
19
-
20
- const user = await ctx.db
21
- .query(TABLE_SLUG_USERS)
22
- .withIndex("by_email", (q) => q.eq("email", identity.email!))
23
- .first()
24
-
25
- if (!user) return null
26
-
27
- return {
28
- user: user as Record<string, unknown>,
29
- roles: (user.role as string[]) ?? [],
30
- }
31
- }
32
-
33
- async function requireUser(ctx: GenericQueryCtx<DataModel>) {
34
- const result = await getUser(ctx)
35
- if (!result) {
36
- throw new ConvexError("Not authenticated")
37
- }
38
- return result
39
- }
40
-
41
- function checkPermission(props: Parameters<typeof hasPermission>[0]) {
42
- const result = hasPermission(props)
43
- const denied =
44
- result === false ||
45
- (typeof result === "object" && !Object.values(result).some(Boolean))
46
-
47
- if (denied) {
48
- const grantingRoles: string[] = []
49
- if (props.access) {
50
- for (const role of props.access.roles) {
51
- const check = hasPermission({ ...props, userRoles: [role] })
52
- const allowed =
53
- check === true ||
54
- (typeof check === "object" && Object.values(check).some(Boolean))
55
- if (allowed) grantingRoles.push(role)
56
- }
57
- }
58
-
59
- const rolesHint =
60
- grantingRoles.length > 0
61
- ? ` Requires one of: ${grantingRoles.join(", ")}`
62
- : ""
63
- throw new ConvexError(
64
- `Access denied: "${props.action}" on "${props.resource}".${rolesHint}`,
65
- )
66
- }
67
-
68
- return result
69
- }
70
-
71
- export const generateUploadUrl = mutation({
72
- args: {},
73
- handler: async (ctx) => {
74
- return await ctx.storage.generateUploadUrl()
75
- },
76
- })
77
-
78
- export const createMediaDocument = mutation({
79
- args: {
80
- collectionSlug: v.string(),
81
- fields: v.any(),
82
- },
83
- handler: async (ctx, { collectionSlug, fields }) => {
84
- const match = findCollectionBySlug({ slug: collectionSlug, config })
85
- if (!match) {
86
- throw new ConvexError(`Collection not found: ${collectionSlug}`)
87
- }
88
-
89
- const { user, roles } = await requireUser(ctx)
90
-
91
- checkPermission({
92
- access,
93
- user,
94
- userRoles: roles,
95
- resource: collectionSlug,
96
- action: "create",
97
- data: fields as Record<string, unknown>,
98
- })
99
-
100
- return await Media.createMediaDocument<DataModel>({
101
- ctx,
102
- args: {
103
- collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
104
- fields: fields as Record<string, unknown>,
105
- collectionFields: match.fields,
106
- },
107
- })
108
- },
109
- })
110
-
111
- export const paginatedSearchDocuments = query({
112
- args: {
113
- collectionSlug: v.string(),
114
- searchIndexName: v.string(),
115
- searchField: v.string(),
116
- query: v.string(),
117
- paginationOpts: paginationOptsValidator,
118
- },
119
- handler: async (ctx, { collectionSlug, searchIndexName, searchField, query: searchQuery, paginationOpts }) => {
120
- const result = await Media.paginatedSearchDocuments<DataModel>({
121
- args: {
122
- collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
123
- searchIndexName,
124
- searchField,
125
- query: searchQuery,
126
- paginationOpts,
127
- },
128
- ctx,
129
- })
130
-
131
- // Filter by read permission if user is authenticated
132
- const auth = await getUser(ctx)
133
- if (!auth) return result
134
-
135
- const filteredPage = result.page.filter((doc: Record<string, unknown>) => {
136
- const readAllowed = hasPermission({
137
- access,
138
- user: auth.user,
139
- userRoles: auth.roles,
140
- resource: collectionSlug,
141
- action: "read",
142
- data: doc,
143
- })
144
- return readAllowed === true
145
- })
146
-
147
- return { ...result, page: filteredPage }
148
- },
149
- })
150
-
151
- /**
152
- * Downloads a file from a URL and stores it in Convex storage.
153
- * Returns storageId + file metadata for the client to create a media document.
154
- */
155
- export const downloadAndStoreUrl = action({
156
- args: {
157
- url: v.string(),
158
- maxSize: v.number(),
159
- },
160
- handler: async (ctx, { url, maxSize }) => {
161
- // 1. Validate URL
162
- let parsedUrl: URL
163
- try {
164
- parsedUrl = new URL(url)
165
- } catch {
166
- throw new ConvexError("Invalid URL")
167
- }
168
-
169
- // 2. Fetch with timeout
170
- const controller = new AbortController()
171
- const timeoutId = setTimeout(() => controller.abort(), 30000)
172
-
173
- let response: Response
174
- try {
175
- response = await fetch(parsedUrl.href, { signal: controller.signal })
176
- } catch (err: any) {
177
- clearTimeout(timeoutId)
178
- if (err.name === "AbortError") {
179
- throw new ConvexError("URL fetch timed out")
180
- }
181
- throw new ConvexError(`Failed to fetch URL: ${err.message}`)
182
- } finally {
183
- clearTimeout(timeoutId)
184
- }
185
-
186
- if (!response.ok) {
187
- throw new ConvexError(`Failed to fetch URL: ${response.statusText}`)
188
- }
189
-
190
- // 3. Reject HTML
191
- const contentType = response.headers.get("Content-Type") || ""
192
- if (contentType.includes("text/html")) {
193
- throw new ConvexError("URL points to an HTML page, not a file")
194
- }
195
-
196
- // 4. Read body and check size
197
- const arrayBuffer = await response.arrayBuffer()
198
- if (arrayBuffer.byteLength > maxSize) {
199
- throw new ConvexError(
200
- `File size (${arrayBuffer.byteLength} bytes) exceeds maximum allowed (${maxSize} bytes)`,
201
- )
202
- }
203
-
204
- // 5. Extract filename
205
- let filename = "download"
206
- try {
207
- const pathname = decodeURIComponent(parsedUrl.pathname)
208
- const segments = pathname.split("/").filter(Boolean)
209
- if (segments.length > 0) {
210
- const lastSegment = segments[segments.length - 1]
211
- // Strip query-like suffixes that might leak through
212
- const clean = lastSegment.split("?")[0].split("#")[0]
213
- if (clean) filename = clean
214
- }
215
- } catch {
216
- // Keep default filename
217
- }
218
-
219
- // Check Content-Disposition for filename
220
- const disposition = response.headers.get("Content-Disposition")
221
- if (disposition) {
222
- const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i)
223
- if (filenameMatch?.[1]) {
224
- filename = decodeURIComponent(filenameMatch[1].trim())
225
- }
226
- }
227
-
228
- // 6. Extract mimeType
229
- let mimeType = "application/octet-stream"
230
- if (contentType) {
231
- mimeType = contentType.split(";")[0].trim()
232
- }
233
-
234
- // 7. Store in Convex storage
235
- const blob = new Blob([arrayBuffer], { type: mimeType })
236
- const storageId = await ctx.storage.store(blob)
237
-
238
- // 8. Return metadata
239
- return {
240
- storageId: storageId as string,
241
- filename,
242
- mimeType,
243
- size: arrayBuffer.byteLength,
244
- }
245
- },
246
- })
1
+ import { createGetAuth } from "@vexcms/better-auth";
2
+ import { mediaApi } from "@vexcms/core";
3
+
4
+ import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
5
+ import config from "~/vex.config";
6
+
7
+ import { mutation, query } from "../_generated/server";
8
+
9
+ export const { getUrl, generateUploadUrl, createMediaDocument, deleteMedia } = mediaApi({
10
+ config,
11
+ query,
12
+ mutation,
13
+ getAuth: createGetAuth({
14
+ orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
15
+ userCollectionSlug: TABLE_SLUG_USERS,
16
+ sessionCollectionSlug: TABLE_SLUG_SESSIONS,
17
+ resolveOrgs: true,
18
+ }),
19
+ });
@@ -0,0 +1,138 @@
1
+ // ⚠️ AUTO-GENERATED BY VEX CMS — DO NOT EDIT ⚠️
2
+ // Run 'vex dev' or 'vex generate' to update this file.
3
+
4
+ import { defineTable } from "convex/server"
5
+ import { v } from "convex/values"
6
+
7
+ export const user = defineTable({
8
+ name: v.string(),
9
+ email: v.string(),
10
+ emailVerified: v.boolean(),
11
+ image: v.optional(v.string()),
12
+ createdAt: v.number(),
13
+ updatedAt: v.number(),
14
+ role: v.optional(v.string()),
15
+ banned: v.optional(v.boolean()),
16
+ banReason: v.optional(v.string()),
17
+ banExpires: v.optional(v.number()),
18
+ isAnonymous: v.optional(v.boolean()),
19
+ userId: v.optional(v.string()),
20
+ roles: v.array(v.string()),
21
+ }).index("by_email", ["email"])
22
+
23
+ export const session = defineTable({
24
+ expiresAt: v.number(),
25
+ token: v.string(),
26
+ createdAt: v.number(),
27
+ updatedAt: v.number(),
28
+ ipAddress: v.optional(v.string()),
29
+ userAgent: v.optional(v.string()),
30
+ userId: v.string(),
31
+ impersonatedBy: v.optional(v.string()),
32
+ activeOrganizationId: v.optional(v.string()),
33
+ })
34
+ .index("by_token", ["token"])
35
+ .index("by_userId", ["userId"])
36
+
37
+ export const account = defineTable({
38
+ accountId: v.string(),
39
+ providerId: v.string(),
40
+ userId: v.string(),
41
+ accessToken: v.optional(v.string()),
42
+ refreshToken: v.optional(v.string()),
43
+ idToken: v.optional(v.string()),
44
+ accessTokenExpiresAt: v.optional(v.number()),
45
+ refreshTokenExpiresAt: v.optional(v.number()),
46
+ scope: v.optional(v.string()),
47
+ password: v.optional(v.string()),
48
+ createdAt: v.number(),
49
+ updatedAt: v.number(),
50
+ }).index("by_userId", ["userId"])
51
+
52
+ export const verification = defineTable({
53
+ identifier: v.string(),
54
+ value: v.string(),
55
+ expiresAt: v.number(),
56
+ createdAt: v.number(),
57
+ updatedAt: v.number(),
58
+ }).index("by_identifier", ["identifier"])
59
+
60
+ export const apikey = defineTable({
61
+ configId: v.string(),
62
+ name: v.optional(v.string()),
63
+ start: v.optional(v.string()),
64
+ referenceId: v.string(),
65
+ prefix: v.optional(v.string()),
66
+ key: v.string(),
67
+ refillInterval: v.optional(v.number()),
68
+ refillAmount: v.optional(v.number()),
69
+ lastRefillAt: v.optional(v.number()),
70
+ enabled: v.optional(v.boolean()),
71
+ rateLimitEnabled: v.optional(v.boolean()),
72
+ rateLimitTimeWindow: v.optional(v.number()),
73
+ rateLimitMax: v.optional(v.number()),
74
+ requestCount: v.optional(v.number()),
75
+ remaining: v.optional(v.number()),
76
+ lastRequest: v.optional(v.number()),
77
+ expiresAt: v.optional(v.number()),
78
+ createdAt: v.number(),
79
+ updatedAt: v.number(),
80
+ permissions: v.optional(v.string()),
81
+ metadata: v.optional(v.string()),
82
+ })
83
+ .index("by_configId", ["configId"])
84
+ .index("by_referenceId", ["referenceId"])
85
+ .index("by_key", ["key"])
86
+
87
+ export const jwks = defineTable({
88
+ publicKey: v.string(),
89
+ privateKey: v.string(),
90
+ createdAt: v.number(),
91
+ expiresAt: v.optional(v.number()),
92
+ })
93
+
94
+ export const organization = defineTable({
95
+ name: v.string(),
96
+ slug: v.string(),
97
+ logo: v.optional(v.string()),
98
+ createdAt: v.number(),
99
+ metadata: v.optional(v.string()),
100
+ }).index("by_slug", ["slug"])
101
+
102
+ export const member = defineTable({
103
+ organizationId: v.string(),
104
+ userId: v.string(),
105
+ role: v.string(),
106
+ createdAt: v.number(),
107
+ })
108
+ .index("by_organizationId", ["organizationId"])
109
+ .index("by_userId", ["userId"])
110
+
111
+ export const invitation = defineTable({
112
+ organizationId: v.string(),
113
+ email: v.string(),
114
+ role: v.optional(v.string()),
115
+ status: v.string(),
116
+ expiresAt: v.number(),
117
+ createdAt: v.number(),
118
+ inviterId: v.string(),
119
+ })
120
+ .index("by_organizationId", ["organizationId"])
121
+ .index("by_email", ["email"])
122
+
123
+ export const images = defineTable({
124
+ filename: v.string(),
125
+ alt: v.string(),
126
+ mimeType: v.string(),
127
+ size: v.number(),
128
+ storageId: v.string(),
129
+ deleted: v.optional(v.boolean()),
130
+ src: v.string(),
131
+ width: v.optional(v.number()),
132
+ height: v.optional(v.number()),
133
+ })
134
+ .index("by_deleted", ["deleted"])
135
+ .searchIndex("search_filename", {
136
+ searchField: "filename",
137
+ filterFields: ["alt"],
138
+ })
@@ -0,0 +1,19 @@
1
+ import { createGetAuth } from "@vexcms/better-auth";
2
+ import { collectionsApi } from "@vexcms/core/server";
3
+
4
+ import { TABLE_SLUG_ORGANIZATIONS, TABLE_SLUG_SESSIONS, TABLE_SLUG_USERS } from "~/db/constants";
5
+ import config from "~/vex.config";
6
+
7
+ import { mutation, query } from "./_generated/server";
8
+
9
+ export const { find, get, search, create, update, remove } = collectionsApi({
10
+ config,
11
+ query,
12
+ mutation,
13
+ getAuth: createGetAuth({
14
+ orgCollectionSlug: TABLE_SLUG_ORGANIZATIONS,
15
+ userCollectionSlug: TABLE_SLUG_USERS,
16
+ sessionCollectionSlug: TABLE_SLUG_SESSIONS,
17
+ resolveOrgs: true,
18
+ }),
19
+ });
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "./node_modules/convex/schemas/convex.schema.json",
3
+ "node": {
4
+ "externalPackages": ["*"]
5
+ }
6
+ }