diff --git a/packages/app/src/db/crud/user.ts b/packages/app/src/db/crud/user.ts index 48d92afd..f61d157f 100644 --- a/packages/app/src/db/crud/user.ts +++ b/packages/app/src/db/crud/user.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, or } from "drizzle-orm"; +import { eq, ilike, or } from "drizzle-orm"; import { db } from "@/db"; import { User, UserInsert, user } from "@common/db/schema/user"; @@ -36,36 +36,82 @@ export async function readUserById(id: string): Promise { return selectedUser; } +/** + * Creates a new user in the database with robust race condition handling. + * + * This function handles several scenarios to prevent duplicate key violations: + * 1. User already exists with the same authId - returns existing user + * 2. User exists with same email but different authId - updates authId and returns user + * 3. No existing user - creates new user with conflict handling + * 4. Race condition where another transaction created the user - catches error and returns existing user + * + * The function uses database transactions and proper error handling to ensure + * that concurrent user creation requests don't result in duplicate key violations. + * + * @param userInsert - The user data to insert + * @returns The created or existing user + * @throws Error if user creation fails for reasons other than race conditions + */ export async function createUser(userInsert: UserInsert): Promise { - // this is done in transaction to avoid race condition when creating user, for conflicts on authId + // Use database-level upsert to eliminate race conditions entirely return await db.transaction(async (trx) => { - // First try to find the user by authId - const [existingUser] = await trx + // First try to find the user by authId (primary identifier) + const [existingUserByAuthId] = await trx .select() .from(user) - .where( - and( - eq(user.authId, userInsert.authId), - eq(user.email, userInsert.email), - ), - ) + .where(eq(user.authId, userInsert.authId)) .execute(); - if (existingUser) { - return existingUser; + if (existingUserByAuthId) { + return existingUserByAuthId; } - // If no existing user found, create a new one + // Also check for existing user by email to handle edge cases + const [existingUserByEmail] = await trx + .select() + .from(user) + .where(eq(user.email, userInsert.email)) + .execute(); + + if (existingUserByEmail) { + // If user exists by email but not by authId, update the authId + const [updatedUser] = await trx + .update(user) + .set({ + authId: userInsert.authId, + firstName: userInsert.firstName, + lastName: userInsert.lastName, + picture: userInsert.picture, + privacyPolicyAcceptedAt: userInsert.privacyPolicyAcceptedAt, + termsOfUseAcceptedAt: userInsert.termsOfUseAcceptedAt, + updatedAt: new Date(), + }) + .where(eq(user.id, existingUserByEmail.id)) + .returning() + .execute(); + + return updatedUser; + } + + // Create new user with database-level upsert for additional safety + // Use INSERT ... ON CONFLICT to handle any remaining race conditions at the database level const [insertedUser] = await trx .insert(user) .values(userInsert) - .returning() .onConflictDoUpdate({ - target: [user.email], + target: [user.authId], set: { - authId: userInsert.authId, + // Update all fields in case of conflict + email: userInsert.email, + firstName: userInsert.firstName, + lastName: userInsert.lastName, + picture: userInsert.picture, + privacyPolicyAcceptedAt: userInsert.privacyPolicyAcceptedAt, + termsOfUseAcceptedAt: userInsert.termsOfUseAcceptedAt, + updatedAt: new Date(), }, }) + .returning() .execute(); return insertedUser; diff --git a/packages/app/src/lib/auth.ts b/packages/app/src/lib/auth.ts index 90f765d9..4ba022b4 100644 --- a/packages/app/src/lib/auth.ts +++ b/packages/app/src/lib/auth.ts @@ -71,10 +71,12 @@ export const getServerUser = async (): Promise => { return user; } + // User doesn't exist, create them + // The createUser function handles race conditions at the database level const email = clerkUser.emailAddresses[0]?.emailAddress; if (!email) { - throw new Error(" New user has no email address"); + throw new Error("New user has no email address"); } const createdUser = await createUser({