It costs 1 USD per org in Clerk, so I'm creating an org in Convex.
export const users = defineTable({
// Clerk fields
clerkId: v.string(), // Clerk user ID (sub from JWT)
email: v.string(),
emailVerified: v.optional(v.boolean()),
name: v.optional(v.string()),
image: v.optional(v.string()),
// Organization (auto-generated UUID)
organizationId: v.string(), // Auto-generated UUID
organizationName: v.optional(v.string()),
// Role - 6 distinct roles
role: v.union(
v.literal("StoreOwner"),
v.literal("StoreTeam"),
v.literal("AgencyAdmin"),
v.literal("AgencyTeam"),
v.literal("MeyooFounder"),
v.literal("MeyooTeam"),
),
// Subscription
plan: v.optional(
v.union(
v.literal("free"),
v.literal("starter"),
v.literal("growth"),
v.literal("business"),
v.literal("enterprise"),
),
),
// Business profile
businessType: v.optional(v.string()),
businessCategory: v.optional(v.string()),
industry: v.optional(v.string()),
// Contact information
mobileNumber: v.optional(v.string()),
mobileCountryCode: v.optional(v.string()),
// Onboarding
onboardingStep: v.optional(v.number()),
isOnboarded: v.optional(v.boolean()),
onboardingData: v.optional(
v.object({
referralSource: v.optional(v.string()),
setupDate: v.optional(v.string()),
completedSteps: v.optional(v.array(v.string())),
}),
),
// Settings
timezone: v.optional(v.string()),
locale: v.optional(v.string()),
// Preferences
emailNotifications: v.optional(v.boolean()),
marketingEmails: v.optional(v.boolean()),
twoFactorEnabled: v.optional(v.boolean()),
// Integration flags
hasShopifyConnection: v.optional(v.boolean()),
hasMetaConnection: v.optional(v.boolean()),
hasGoogleConnection: v.optional(v.boolean()),
isInitialSyncComplete: v.optional(v.boolean()),
primaryCurrency: v.optional(v.string()),
// User status
status: v.optional(
v.union(
v.literal("active"),
v.literal("inactive"),
v.literal("suspended"),
v.literal("deleted"),
),
),
deletedAt: v.optional(v.number()), // Track when user was deleted
lastLoginAt: v.optional(v.number()),
loginCount: v.optional(v.number()),
// For agencies - which organizations they can access
agencyOrganizations: v.optional(v.array(v.string())),
// Metadata
createdAt: v.number(),
updatedAt: v.optional(v.number()),
})
.index("by_clerk_id", ["clerkId"])
.index("by_email", ["email"])
.index("by_organization", ["organizationId"])
.index("by_isOnboarded_and_onboardingStep", ["isOnboarded", "onboardingStep"])
.index("by_role", ["role"]);
import { httpRouter } from "convex/server";
import { handleUser } from "./webhooks/clerkHandler";
const http = httpRouter();
// Single Clerk webhook endpoint for all user events
http.route({
path: "/clerk/handle-user",
method: "POST",
handler: handleUser,
});
export default http;
Convex/webhooks/clerkHandler
I m doing alot of console.log , feel free to remove it
import { Webhook } from "svix";
import { httpAction } from "../_generated/server";
import { internal } from "../_generated/api";
/**
* Clerk Webhook Handler
* Processes user lifecycle events from Clerk
*/
// Clerk webhook event types
type ClerkWebhookEvent = {
data: Record<string, any>;
object: "event";
type:
| "user.created"
| "user.updated"
| "user.deleted"
| "session.created"
| "session.ended";
};
/**
* Handle all user-related webhooks from Clerk
*/
export const handleUser = httpAction(async (ctx, request) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET;
if (!webhookSecret) {
console.error("CLERK_WEBHOOK_SECRET not configured");
return new Response("Webhook secret not configured", { status: 500 });
}
// Get the headers
const svixId = request.headers.get("svix-id");
const svixTimestamp = request.headers.get("svix-timestamp");
const svixSignature = request.headers.get("svix-signature");
// If any are missing, reject the request
if (!svixId || !svixTimestamp || !svixSignature) {
return new Response("Missing svix headers", { status: 400 });
}
// Get the body
const body = await request.text();
// Verify the webhook signature
const wh = new Webhook(webhookSecret);
let evt: ClerkWebhookEvent;
try {
evt = wh.verify(body, {
"svix-id": svixId,
"svix-timestamp": svixTimestamp,
"svix-signature": svixSignature,
}) as ClerkWebhookEvent;
} catch (err: any) {
console.error("Error verifying webhook:", err.message);
return new Response("Invalid signature", { status: 400 });
}
// Process the webhook based on type
const { type, data } = evt;
console.log(`Processing Clerk webhook: ${type}`);
try {
switch (type) {
case "user.created": {
// Extract user data
const userData = {
clerkId: data.id,
email: data.email_addresses?.[0]?.email_address,
emailVerified:
data.email_addresses?.[0]?.verification?.status === "verified",
name:
`${data.first_name || ""} ${data.last_name || ""}`.trim() ||
data.username,
image: data.image_url,
publicMetadata: data.public_metadata || {},
};
// Create user in Convex
await ctx.runMutation(
internal.core.users.createUserFromClerk,
userData,
);
console.log(`Created user ${data.id}`);
break;
}
case "user.updated": {
// Extract updated user data
const userData = {
clerkId: data.id,
email: data.email_addresses?.[0]?.email_address,
emailVerified:
data.email_addresses?.[0]?.verification?.status === "verified",
name:
`${data.first_name || ""} ${data.last_name || ""}`.trim() ||
data.username,
image: data.image_url,
publicMetadata: data.public_metadata || {},
};
// Update user in Convex
await ctx.runMutation(
internal.core.users.updateUserFromClerk,
userData,
);
console.log(`Updated user ${data.id}`);
break;
}
case "user.deleted": {
// Delete user from Convex (soft delete)
await ctx.runMutation(internal.core.users.deleteUserFromClerk, {
clerkId: data.id,
});
console.log(`Deleted user ${data.id}`);
break;
}
case "session.created": {
// Track user login activity
const user = await ctx.runQuery(internal.core.users.getUserByClerkId, {
clerkId: data.user_id,
});
if (user && user.organizationId) {
// Emit login event for activity tracking
await ctx.runMutation(internal.engine.events.emitEvent, {
type: "user:login",
organizationId: user.organizationId,
userId: user._id,
metadata: {
sessionId: data.id,
clientId: data.client_id,
},
});
}
console.log(`Session created for user ${data.user_id}`);
break;
}
case "session.ended": {
// Track user logout activity
const user = await ctx.runQuery(internal.core.users.getUserByClerkId, {
clerkId: data.user_id,
});
if (user && user.organizationId) {
// Emit logout event
await ctx.runMutation(internal.engine.events.emitEvent, {
type: "user:logout",
organizationId: user.organizationId,
userId: user._id,
metadata: {
sessionId: data.id,
},
});
}
console.log(`Session ended for user ${data.user_id}`);
break;
}
default:
console.log(`Unhandled webhook type: ${type}`);
}
return new Response("Webhook processed successfully", { status: 200 });
} catch (error: any) {
console.error(`Error processing webhook ${type}:`, error);
// Return 200 to prevent retries for processing errors
// (only return error status for signature/validation issues)
return new Response("Webhook processing failed", { status: 200 });
}
});
Note: it has some extra funtions feel free to ignore that ,
import { v } from "convex/values";
import {
query,
mutation,
internalQuery,
internalMutation,
} from "../_generated/server";
/**
* User management and Clerk webhook handling
*/
// User validator for returns types
const userValidator = v.object({
_id: v.id("users"),
_creationTime: v.number(),
clerkId: v.string(),
email: v.string(),
emailVerified: v.optional(v.boolean()),
name: v.optional(v.string()),
image: v.optional(v.string()),
organizationId: v.optional(v.string()),
organizationName: v.optional(v.string()),
role: v.union(
v.literal("StoreOwner"),
v.literal("StoreTeam"),
v.literal("AgencyAdmin"),
v.literal("AgencyTeam"),
v.literal("MeyooFounder"),
v.literal("MeyooTeam")
),
plan: v.optional(
v.union(
v.literal("free"),
v.literal("starter"),
v.literal("growth"),
v.literal("business"),
v.literal("enterprise")
)
),
businessType: v.optional(v.string()),
businessCategory: v.optional(v.string()),
industry: v.optional(v.string()),
mobileNumber: v.optional(v.string()),
mobileCountryCode: v.optional(v.string()),
onboardingStep: v.optional(v.number()),
isOnboarded: v.optional(v.boolean()),
onboardingData: v.optional(
v.object({
referralSource: v.optional(v.string()),
setupDate: v.optional(v.string()),
completedSteps: v.optional(v.array(v.string())),
})
),
timezone: v.optional(v.string()),
locale: v.optional(v.string()),
emailNotifications: v.optional(v.boolean()),
marketingEmails: v.optional(v.boolean()),
twoFactorEnabled: v.optional(v.boolean()),
hasShopifyConnection: v.optional(v.boolean()),
hasMetaConnection: v.optional(v.boolean()),
hasGoogleConnection: v.optional(v.boolean()),
isInitialSyncComplete: v.optional(v.boolean()),
primaryCurrency: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("active"),
v.literal("inactive"),
v.literal("suspended"),
v.literal("deleted")
)
),
deletedAt: v.optional(v.number()),
lastLoginAt: v.optional(v.number()),
loginCount: v.optional(v.number()),
agencyOrganizations: v.optional(v.array(v.string())),
createdAt: v.number(),
updatedAt: v.optional(v.number()),
});
// ============ QUERIES ============
/**
* Get current authenticated user
*/
export const getCurrentUser = query({
args: {},
returns: v.union(v.null(), userValidator),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();
},
});
// ============ INTERNAL MUTATIONS (Clerk Webhooks) ============
/**
* Create user from Clerk webhook
*/
export const createUserFromClerk = internalMutation({
args: {
clerkId: v.string(),
email: v.optional(v.string()),
emailVerified: v.boolean(),
name: v.optional(v.string()),
image: v.optional(v.string()),
publicMetadata: v.any(),
},
returns: v.id("users"),
handler: async (ctx, args) => {
// Check if user already exists
const existingUser = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
.first();
if (existingUser) {
console.log(`User ${args.clerkId} already exists`);
return existingUser._id;
}
// Generate a unique organization ID for new user
const organizationId = generateOrganizationId();
// Create new user
const userId = await ctx.db.insert("users", {
clerkId: args.clerkId,
email: args.email || "",
emailVerified: args.emailVerified,
name: args.name || "",
image: args.image,
organizationId,
role: "StoreOwner", // Default role for new users
status: "active",
// Onboarding
isOnboarded: false,
onboardingStep: 0,
hasShopifyConnection: false,
hasMetaConnection: false,
hasGoogleConnection: false,
// Preferences
timezone: "UTC",
emailNotifications: true,
marketingEmails: false,
// Metadata
createdAt: Date.now(),
updatedAt: Date.now(),
});
console.log(
`Created new user ${userId} with organization ${organizationId}`
);
return userId;
},
});
/**
* Update user from Clerk webhook
*/
export const updateUserFromClerk = internalMutation({
args: {
clerkId: v.string(),
email: v.optional(v.string()),
emailVerified: v.boolean(),
name: v.optional(v.string()),
image: v.optional(v.string()),
publicMetadata: v.any(),
},
returns: v.id("users"),
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
.first();
if (!user) {
console.log(`User ${args.clerkId} not found for update`);
// Create the user if it doesn't exist - inline the creation logic
const organizationId = generateOrganizationId();
const userId = await ctx.db.insert("users", {
clerkId: args.clerkId,
email: args.email || "",
emailVerified: args.emailVerified,
name: args.name,
image: args.image,
organizationId,
role: "StoreOwner",
status: "active" as const,
isOnboarded: false,
onboardingStep: 0,
hasShopifyConnection: false,
hasMetaConnection: false,
hasGoogleConnection: false,
timezone: "UTC",
emailNotifications: true,
marketingEmails: false,
createdAt: Date.now(),
updatedAt: Date.now(),
});
return userId;
}
const updates: Partial<{
updatedAt: number;
email: string;
emailVerified: boolean;
name: string | undefined;
image: string | undefined;
}> = {
updatedAt: Date.now(),
};
if (typeof args.email === "string" && args.email.trim() !== "") {
updates.email = args.email;
}
// Always trust Clerk's email verification status
updates.emailVerified = args.emailVerified;
if (typeof args.name === "string" && args.name.trim() !== "") {
updates.name = args.name;
}
if (typeof args.image === "string" && args.image.trim() !== "") {
updates.image = args.image;
}
// Note: publicMetadata is not stored in the database
await ctx.db.patch(user._id, updates);
console.log(`Updated user ${user._id}`);
return user._id;
},
});
/**
* Delete user from Clerk webhook
*/
export const deleteUserFromClerk = internalMutation({
args: {
clerkId: v.string(),
},
returns: v.union(v.null(), v.id("users")),
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
.first();
if (!user) {
console.log(`User ${args.clerkId} not found for deletion`);
return null;
}
// Soft delete - mark as deleted instead of removing
await ctx.db.patch(user._id, {
status: "deleted" as const,
deletedAt: Date.now(),
updatedAt: Date.now(),
});
console.log(`Soft deleted user ${user._id}`);
return user._id;
},
});
// ============ INTERNAL QUERIES ============
/**
* Get user by Clerk ID (internal)
*/
export const getUserByClerkId = internalQuery({
args: {
clerkId: v.string(),
},
returns: v.union(v.null(), userValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
.first();
},
});
/**
* Get users by organization (internal)
*/
export const getUsersByOrganization = internalQuery({
args: {
organizationId: v.string(),
},
returns: v.array(userValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("users")
.withIndex("by_organization", (q) =>
q.eq("organizationId", args.organizationId)
)
.collect();
},
});
// ============ HELPER FUNCTIONS ============
/**
* Generate unique organization ID
*/
function generateOrganizationId(): string {
// Generate UUID-like string for organization
const segments = [
Math.random().toString(36).substring(2, 10),
Math.random().toString(36).substring(2, 6),
Math.random().toString(36).substring(2, 6),
Math.random().toString(36).substring(2, 6),
Math.random().toString(36).substring(2, 14),
];
return segments.join("-");
}
/**
* Check if user has permission for action
*/
export function hasPermission(
user: { role: string },
action: "view" | "edit" | "delete" | "admin"
): boolean {
switch (user.role) {
case "StoreOwner":
case "MeyooFounder":
return true; // Full access
case "StoreTeam":
return action === "view" || action === "edit";
case "AgencyAdmin":
return action !== "delete"; // Can do everything except delete
case "AgencyTeam":
return action === "view";
case "MeyooTeam":
return action === "view" || action === "edit";
default:
return false;
}
}