@ramonclaudio/create-vexpo 0.1.0

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 (174) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,114 @@
1
+ import { v } from "convex/values";
2
+
3
+ import { internal } from "./_generated/api";
4
+ import { internalMutation } from "./_generated/server";
5
+ import { authMutation, authQuery } from "./functions";
6
+ import { deviceTypeValidator } from "./validators";
7
+
8
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
9
+ const CLEANUP_BATCH = 200;
10
+
11
+ export const upsert = authMutation({
12
+ args: { token: v.string(), deviceType: deviceTypeValidator },
13
+ returns: v.id("pushTokens"),
14
+ handler: async (ctx, { token, deviceType }) => {
15
+ const now = Date.now();
16
+ // Token may belong to a different user (device transferred), so read it
17
+ // by token first and reassign if needed.
18
+ const existing = await ctx.db
19
+ .query("pushTokens")
20
+ .withIndex("by_token", (q) => q.eq("token", token))
21
+ .unique();
22
+
23
+ if (existing) {
24
+ if (existing.userId === ctx.user._id) {
25
+ await ctx.db.patch(existing._id, { updatedAt: now });
26
+ return existing._id;
27
+ }
28
+ // Reassign token to current user (device changed owners)
29
+ await ctx.db.patch(existing._id, {
30
+ userId: ctx.user._id,
31
+ deviceType,
32
+ updatedAt: now,
33
+ });
34
+ return existing._id;
35
+ }
36
+
37
+ return ctx.db.insert("pushTokens", {
38
+ userId: ctx.user._id,
39
+ token,
40
+ deviceType,
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ });
44
+ },
45
+ });
46
+
47
+ export const remove = authMutation({
48
+ args: { token: v.string() },
49
+ returns: v.null(),
50
+ handler: async (ctx, { token }) => {
51
+ const existing = await ctx.db
52
+ .query("pushTokens")
53
+ .withIndex("by_token", (q) => q.eq("token", token))
54
+ .unique();
55
+
56
+ if (existing && existing.userId === ctx.user._id) {
57
+ await ctx.db.delete(existing._id);
58
+ }
59
+ return null;
60
+ },
61
+ });
62
+
63
+ export const list = authQuery({
64
+ args: {},
65
+ returns: v.array(
66
+ v.object({
67
+ _id: v.id("pushTokens"),
68
+ _creationTime: v.number(),
69
+ userId: v.string(),
70
+ token: v.string(),
71
+ deviceType: deviceTypeValidator,
72
+ createdAt: v.number(),
73
+ updatedAt: v.number(),
74
+ }),
75
+ ),
76
+ handler: async (ctx) => {
77
+ return ctx.db
78
+ .query("pushTokens")
79
+ .withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
80
+ .collect();
81
+ },
82
+ });
83
+
84
+ export const removeAll = authMutation({
85
+ args: {},
86
+ returns: v.null(),
87
+ handler: async (ctx) => {
88
+ const tokens = await ctx.db
89
+ .query("pushTokens")
90
+ .withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
91
+ .collect();
92
+ await Promise.all(tokens.map((t) => ctx.db.delete(t._id)));
93
+ return null;
94
+ },
95
+ });
96
+
97
+ /**
98
+ * Delete push tokens older than 30 days, in bounded batches. Reschedules
99
+ * itself when more rows remain so we never load an unbounded set into memory.
100
+ */
101
+ export const cleanupStale = internalMutation({
102
+ args: {},
103
+ returns: v.number(),
104
+ handler: async (ctx) => {
105
+ const cutoff = Date.now() - THIRTY_DAYS_MS;
106
+ const batch = await ctx.db.query("pushTokens").order("asc").take(CLEANUP_BATCH);
107
+ const stale = batch.filter((t) => t._creationTime < cutoff);
108
+ await Promise.all(stale.map((t) => ctx.db.delete(t._id)));
109
+ if (batch.length === CLEANUP_BATCH) {
110
+ await ctx.scheduler.runAfter(0, internal.pushTokens.cleanupStale, {});
111
+ }
112
+ return stale.length;
113
+ },
114
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Rate Limiting Configuration
3
+ *
4
+ * Uses the @convex-dev/rate-limiter component for application-level rate
5
+ * limiting.
6
+ *
7
+ * Authentication-related rate limiting (sign-in, sign-up, password reset)
8
+ * is handled by Better Auth at the HTTP layer. See convex/auth.ts.
9
+ *
10
+ * @see https://www.convex.dev/components/rate-limiter
11
+ */
12
+
13
+ import { HOUR, MINUTE, RateLimiter } from "@convex-dev/rate-limiter";
14
+
15
+ import { components } from "./_generated/api";
16
+ import type { MutationCtx } from "./_generated/server";
17
+
18
+ /**
19
+ * Rate limiter instance using the component.
20
+ * Defines all application rate limits in one place.
21
+ */
22
+ export const rateLimiter = new RateLimiter(components.rateLimiter, {
23
+ // Read operations: permissive for good UX, sharded for throughput
24
+ apiRead: {
25
+ kind: "token bucket",
26
+ rate: 100,
27
+ period: MINUTE,
28
+ capacity: 20,
29
+ shards: 2,
30
+ },
31
+
32
+ // Write operations: stricter to prevent abuse
33
+ apiWrite: {
34
+ kind: "token bucket",
35
+ rate: 30,
36
+ period: MINUTE,
37
+ capacity: 10,
38
+ },
39
+
40
+ // General authenticated user actions
41
+ userAction: {
42
+ kind: "token bucket",
43
+ rate: 60,
44
+ period: MINUTE,
45
+ capacity: 10,
46
+ },
47
+
48
+ // For operations that MUST eventually succeed (use with reserve: true)
49
+ criticalAction: {
50
+ kind: "token bucket",
51
+ rate: 10,
52
+ period: MINUTE,
53
+ capacity: 5,
54
+ maxReserved: 20,
55
+ },
56
+
57
+ // Avatar uploads (product-specific). Generous burst capacity so users
58
+ // tweaking their photo a few times in a row don't trip it.
59
+ avatarUpload: { kind: "token bucket", rate: 30, period: HOUR, capacity: 10 },
60
+ });
61
+
62
+ export type RateLimitName =
63
+ | "apiRead"
64
+ | "apiWrite"
65
+ | "userAction"
66
+ | "criticalAction"
67
+ | "avatarUpload";
68
+
69
+ /**
70
+ * Apply a rate limit and throw automatically if exceeded.
71
+ */
72
+ export async function rateLimitWithThrow(
73
+ ctx: MutationCtx,
74
+ name: RateLimitName,
75
+ key?: string,
76
+ count?: number,
77
+ ) {
78
+ return rateLimiter.limit(ctx, name, { key, count, throws: true });
79
+ }
80
+
81
+ /**
82
+ * Consume rate limit tokens without throwing.
83
+ * Returns { ok, retryAfter } so HTTP callers can build a 429 response.
84
+ */
85
+ export async function consumeLimit(
86
+ ctx: MutationCtx,
87
+ name: RateLimitName,
88
+ key?: string,
89
+ count?: number,
90
+ ) {
91
+ return rateLimiter.limit(ctx, name, { key, count });
92
+ }
@@ -0,0 +1,28 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema(
5
+ {
6
+ // App-specific user row mirrored from Better Auth via auth triggers.
7
+ // Identity fields (name, email, username, image) live on the Better Auth
8
+ // user component and are merged in at read time by safeGetAuthenticatedUser.
9
+ users: defineTable({
10
+ authId: v.string(),
11
+ bio: v.optional(v.string()),
12
+ avatar: v.optional(v.id("_storage")),
13
+ createdAt: v.number(),
14
+ updatedAt: v.number(),
15
+ }).index("authId", ["authId"]),
16
+
17
+ pushTokens: defineTable({
18
+ userId: v.id("users"),
19
+ token: v.string(),
20
+ deviceType: v.literal("ios"),
21
+ createdAt: v.number(),
22
+ updatedAt: v.number(),
23
+ })
24
+ .index("by_user", ["userId"])
25
+ .index("by_token", ["token"]),
26
+ },
27
+ { strictTableNameTypes: true },
28
+ );
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "strict": true,
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "skipLibCheck": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "target": "ESNext",
10
+ "lib": ["ES2021", "dom"],
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "isolatedModules": true,
14
+ "noEmit": true
15
+ },
16
+ "include": ["./**/*"],
17
+ "exclude": ["./_generated"]
18
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * User Queries and Mutations
3
+ *
4
+ * CRUD operations for the app users table.
5
+ * Identity fields (name, email, username, image) live on the Better Auth user
6
+ * and are merged in at read time by safeGetAuthenticatedUser in auth.ts.
7
+ */
8
+
9
+ import { v } from "convex/values";
10
+
11
+ import { components } from "./_generated/api";
12
+ import type { Id } from "./_generated/dataModel";
13
+ import type { MutationCtx } from "./_generated/server";
14
+ import { authComponent, authUserValidator } from "./auth";
15
+ import { validationError } from "./errors";
16
+ import { authMutation, optionalAuthQuery } from "./functions";
17
+ import { rateLimitWithThrow } from "./rateLimit";
18
+ import {
19
+ paginatedUsersValidator,
20
+ publicUserProfileValidator,
21
+ userProfileUpdateFields,
22
+ validateBio,
23
+ } from "./validators";
24
+
25
+ // ============================================================================
26
+ // Queries
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Get the current authenticated user's profile with resolved avatar URL.
31
+ * Returns null when unauthenticated.
32
+ */
33
+ export const getMe = optionalAuthQuery({
34
+ args: {},
35
+ returns: v.union(authUserValidator, v.null()),
36
+ handler: async (ctx) => {
37
+ return ctx.user ?? null;
38
+ },
39
+ });
40
+
41
+ /**
42
+ * Get a user by app user id with Better Auth identity fields merged in.
43
+ * Accepts an arbitrary string and normalizes it via `ctx.db.normalizeId`,
44
+ * so untrusted inputs can be passed straight through. Returns null when the
45
+ * id is malformed or either record is missing.
46
+ */
47
+ export const getUser = optionalAuthQuery({
48
+ args: { userId: v.string() },
49
+ returns: v.union(publicUserProfileValidator, v.null()),
50
+ handler: async (ctx, args) => {
51
+ const id = ctx.db.normalizeId("users", args.userId);
52
+ if (!id) return null;
53
+
54
+ const user = await ctx.db.get(id);
55
+ if (!user) return null;
56
+
57
+ const authUser = await authComponent.getAnyUserById(ctx, user.authId);
58
+ if (!authUser) return null;
59
+
60
+ const avatarUrl = user.avatar
61
+ ? await ctx.storage.getUrl(user.avatar)
62
+ : (authUser.image ?? null);
63
+
64
+ return {
65
+ _id: user._id,
66
+ _creationTime: user._creationTime,
67
+ name: authUser.name,
68
+ username:
69
+ (authUser as { displayUsername?: string | null }).displayUsername ??
70
+ (authUser as { username?: string | null }).username ??
71
+ null,
72
+ avatarUrl,
73
+ bio: user.bio,
74
+ };
75
+ },
76
+ });
77
+
78
+ /**
79
+ * List users (paginated) with Better Auth identity fields merged in.
80
+ * Entries with a missing Better Auth record are skipped.
81
+ */
82
+ export const listUsers = optionalAuthQuery({
83
+ args: {
84
+ cursor: v.optional(v.string()),
85
+ limit: v.optional(v.number()),
86
+ },
87
+ returns: paginatedUsersValidator,
88
+ handler: async (ctx, args) => {
89
+ const limit = Math.min(Math.max(args.limit ?? 20, 1), 100);
90
+
91
+ const results = await ctx.db
92
+ .query("users")
93
+ .order("desc")
94
+ .paginate({ cursor: args.cursor ?? null, numItems: limit });
95
+
96
+ const page = await Promise.all(
97
+ results.page.map(async (user) => {
98
+ const authUser = await authComponent.getAnyUserById(ctx, user.authId);
99
+ if (!authUser) return null;
100
+ const avatarUrl = user.avatar
101
+ ? await ctx.storage.getUrl(user.avatar)
102
+ : (authUser.image ?? null);
103
+ return {
104
+ _id: user._id,
105
+ _creationTime: user._creationTime,
106
+ name: authUser.name,
107
+ username:
108
+ (authUser as { displayUsername?: string | null }).displayUsername ??
109
+ (authUser as { username?: string | null }).username ??
110
+ null,
111
+ avatarUrl,
112
+ bio: user.bio,
113
+ };
114
+ }),
115
+ );
116
+
117
+ return {
118
+ page: page.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
119
+ continueCursor: results.continueCursor,
120
+ isDone: results.isDone,
121
+ };
122
+ },
123
+ });
124
+
125
+ // ============================================================================
126
+ // Mutations
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Update the current user's bio. Name and username changes go through
131
+ * Better Auth directly via authClient.updateUser on the client.
132
+ */
133
+ export const updateProfile = authMutation({
134
+ args: userProfileUpdateFields,
135
+ returns: v.id("users"),
136
+ handler: async (ctx, args): Promise<Id<"users">> => {
137
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
138
+
139
+ if (args.bio !== undefined) {
140
+ const result = validateBio(args.bio);
141
+ if (!result.valid) throw validationError(result.error!, "bio");
142
+ }
143
+
144
+ await ctx.db.patch(ctx.user._id, {
145
+ bio: args.bio,
146
+ updatedAt: Date.now(),
147
+ });
148
+
149
+ return ctx.user._id;
150
+ },
151
+ });
152
+
153
+ /**
154
+ * Generate an upload URL for avatar images.
155
+ * The URL expires in 1 hour.
156
+ */
157
+ export const generateAvatarUploadUrl = authMutation({
158
+ args: {},
159
+ returns: v.string(),
160
+ handler: async (ctx) => {
161
+ await rateLimitWithThrow(ctx, "avatarUpload", ctx.user._id.toString());
162
+ return await ctx.storage.generateUploadUrl();
163
+ },
164
+ });
165
+
166
+ /**
167
+ * Update the current user's avatar with a storage id.
168
+ * Deletes the previous uploaded avatar from storage if one exists.
169
+ * Does not touch Better Auth's image field - that's for provider-supplied URLs.
170
+ */
171
+ export const updateAvatar = authMutation({
172
+ args: { storageId: v.id("_storage") },
173
+ returns: v.object({ avatarUrl: v.union(v.string(), v.null()) }),
174
+ handler: async (ctx, args) => {
175
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
176
+
177
+ if (ctx.user.avatar) await ctx.storage.delete(ctx.user.avatar);
178
+
179
+ await ctx.db.patch(ctx.user._id, {
180
+ avatar: args.storageId,
181
+ updatedAt: Date.now(),
182
+ });
183
+
184
+ return { avatarUrl: await ctx.storage.getUrl(args.storageId) };
185
+ },
186
+ });
187
+
188
+ /**
189
+ * Delete the current user's uploaded avatar.
190
+ * Removes the file from storage and clears the avatar field. After deletion,
191
+ * Better Auth's image (e.g. OAuth provider avatar) is used as the fallback.
192
+ */
193
+ export const deleteAvatar = authMutation({
194
+ args: {},
195
+ returns: v.object({ success: v.boolean() }),
196
+ handler: async (ctx) => {
197
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
198
+
199
+ if (ctx.user.avatar) await ctx.storage.delete(ctx.user.avatar);
200
+
201
+ await ctx.db.patch(ctx.user._id, {
202
+ avatar: undefined,
203
+ updatedAt: Date.now(),
204
+ });
205
+
206
+ return { success: true };
207
+ },
208
+ });
209
+
210
+ /**
211
+ * Delete the current user's account.
212
+ * Removes app-owned data (push tokens) and all Better Auth records.
213
+ * The `users` row is dropped by the auth `onDelete` trigger.
214
+ */
215
+ export const deleteAccount = authMutation({
216
+ args: {},
217
+ returns: v.object({ success: v.boolean() }),
218
+ handler: async (ctx) => {
219
+ const authUserId = ctx.user.authUserId;
220
+
221
+ const pushTokens = await ctx.db
222
+ .query("pushTokens")
223
+ .withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
224
+ .collect();
225
+ await Promise.all(pushTokens.map((t) => ctx.db.delete(t._id)));
226
+
227
+ const authUser = await authComponent.safeGetAuthUser(ctx);
228
+
229
+ await deleteAllByUserId(ctx, "session", authUserId);
230
+ await deleteAllByUserId(ctx, "account", authUserId);
231
+ await deleteAllByUserId(ctx, "twoFactor", authUserId);
232
+ await deleteAllByUserId(ctx, "oauthAccessToken", authUserId);
233
+ await deleteAllByUserId(ctx, "oauthConsent", authUserId);
234
+ await deleteAllByUserId(ctx, "oauthApplication", authUserId);
235
+ if (authUser?.email) await deleteVerificationByIdentifier(ctx, authUser.email);
236
+
237
+ // Deleting the Better Auth user fires the `onDelete` trigger which
238
+ // removes the matching app users row and frees the avatar blob.
239
+ await ctx.runMutation(components.betterAuth.adapter.deleteOne, {
240
+ input: { model: "user", where: [{ field: "_id", value: authUserId }] },
241
+ });
242
+
243
+ return { success: true };
244
+ },
245
+ });
246
+
247
+ type UserIdModel =
248
+ | "session"
249
+ | "account"
250
+ | "twoFactor"
251
+ | "oauthAccessToken"
252
+ | "oauthConsent"
253
+ | "oauthApplication";
254
+
255
+ const deleteAllByUserId = async (ctx: MutationCtx, model: UserIdModel, userId: string) => {
256
+ let cursor: string | null = null;
257
+ let isDone = false;
258
+ while (!isDone) {
259
+ const result = (await ctx.runMutation(components.betterAuth.adapter.deleteMany, {
260
+ input: { model, where: [{ field: "userId", value: userId }] },
261
+ paginationOpts: { numItems: 100, cursor },
262
+ })) as { isDone: boolean; continueCursor: string };
263
+ isDone = result.isDone;
264
+ cursor = result.continueCursor;
265
+ }
266
+ };
267
+
268
+ const deleteVerificationByIdentifier = async (ctx: MutationCtx, identifier: string) => {
269
+ let cursor: string | null = null;
270
+ let isDone = false;
271
+ while (!isDone) {
272
+ const result = (await ctx.runMutation(components.betterAuth.adapter.deleteMany, {
273
+ input: { model: "verification", where: [{ field: "identifier", value: identifier }] },
274
+ paginationOpts: { numItems: 100, cursor },
275
+ })) as { isDone: boolean; continueCursor: string };
276
+ isDone = result.isDone;
277
+ cursor = result.continueCursor;
278
+ }
279
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Validator Utilities
3
+ *
4
+ * Function argument and return-type validators.
5
+ * Schema-level field validators live in schema.ts.
6
+ */
7
+
8
+ import { literals } from "convex-helpers/validators";
9
+ import { v } from "convex/values";
10
+
11
+ /**
12
+ * Paginated response structure.
13
+ * Spread into a v.object() alongside the page shape.
14
+ */
15
+ export const paginatedResponseFields = {
16
+ continueCursor: v.string(),
17
+ isDone: v.boolean(),
18
+ };
19
+
20
+ // ============================================================================
21
+ // User Profile Validators
22
+ // ============================================================================
23
+
24
+ /**
25
+ * User profile fields accepted by updateProfile.
26
+ * Name changes go through Better Auth (authClient.updateUser) directly.
27
+ */
28
+ export const userProfileUpdateFields = {
29
+ bio: v.optional(v.string()),
30
+ };
31
+
32
+ /**
33
+ * Public user profile returned by api.users.getUser and in listUsers pages.
34
+ * Merges app-owned fields (bio, avatar storage resolved to URL) with Better
35
+ * Auth identity fields (name, username).
36
+ */
37
+ export const publicUserProfileValidator = v.object({
38
+ _id: v.id("users"),
39
+ _creationTime: v.number(),
40
+ name: v.string(),
41
+ username: v.union(v.string(), v.null()),
42
+ avatarUrl: v.union(v.string(), v.null()),
43
+ bio: v.optional(v.string()),
44
+ });
45
+
46
+ /**
47
+ * Paginated user list response.
48
+ */
49
+ export const paginatedUsersValidator = v.object({
50
+ page: v.array(publicUserProfileValidator),
51
+ ...paginatedResponseFields,
52
+ });
53
+
54
+ // ============================================================================
55
+ // Mobile Validators
56
+ // ============================================================================
57
+
58
+ export const deviceTypeValidator = literals("ios");
59
+
60
+ // ============================================================================
61
+ // Validation Helpers
62
+ // ============================================================================
63
+
64
+ const BIO_MAX_LENGTH = 500;
65
+
66
+ /**
67
+ * Validate a bio field.
68
+ */
69
+ export function validateBio(bio: string): { valid: boolean; error?: string } {
70
+ if (bio.length > BIO_MAX_LENGTH) {
71
+ return { valid: false, error: `Bio must be ${BIO_MAX_LENGTH} characters or less` };
72
+ }
73
+ return { valid: true };
74
+ }