Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 62 additions & 16 deletions packages/app/src/db/crud/user.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -36,36 +36,82 @@ export async function readUserById(id: string): Promise<User | undefined> {
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<User> {
// 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;
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function updates an existing user's authId when found by email (lines 78-93), but this could lead to security issues. If two different authentication providers return the same email address, this code would reassign the user account to the new authId, potentially giving unauthorized access to another user's account.

Copilot uses AI. Check for mistakes.
}

// 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],
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conflict target has been changed from [user.email] to [user.authId], but the code also handles cases where users exist by email but not by authId (lines 70-94). This creates a logical inconsistency where the upsert won't handle email conflicts, potentially causing unique constraint violations on the email field if it has a unique constraint.

Suggested change
target: [user.authId],
target: [user.authId, user.email],

Copilot uses AI. Check for mistakes.
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(),
},
})
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions handling 'remaining race conditions' but the earlier logic already handles user existence by both authId and email. This upsert clause may be redundant given the explicit checks above, making the code unnecessarily complex.

Copilot uses AI. Check for mistakes.
.returning()
.execute();

return insertedUser;
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ export const getServerUser = async (): Promise<User> => {
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({
Expand Down