Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
import { eq, inArray } from "drizzle-orm";
|
2025-10-11 00:53:39 -07:00
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
|
|
|
|
export type User = typeof schema.users.$inferSelect;
|
|
|
|
|
export type NewUser = typeof schema.users.$inferInsert;
|
|
|
|
|
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
/**
|
|
|
|
|
* Returns the best available display identifier for a user.
|
|
|
|
|
* Prefers Clerk username; falls back to displayName (always set from
|
|
|
|
|
* firstName+lastName or email prefix). Returns null only if both are absent.
|
|
|
|
|
*/
|
|
|
|
|
export function getUserDisplayName(
|
|
|
|
|
user: Pick<User, "username" | "displayName">
|
|
|
|
|
): string | null {
|
|
|
|
|
return user.username ?? user.displayName ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-11 00:53:39 -07:00
|
|
|
export async function createUser(data: NewUser): Promise<User> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const [user] = await db.insert(schema.users).values(data).returning();
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function findUserById(id: string): Promise<User | undefined> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return await db.query.users.findFirst({
|
|
|
|
|
where: eq(schema.users.id, id),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function findUserByClerkId(
|
|
|
|
|
clerkId: string
|
|
|
|
|
): Promise<User | undefined> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return await db.query.users.findFirst({
|
|
|
|
|
where: eq(schema.users.clerkId, clerkId),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
export async function findUsersByClerkIds(clerkIds: string[]): Promise<User[]> {
|
|
|
|
|
if (clerkIds.length === 0) return [];
|
|
|
|
|
const db = database();
|
|
|
|
|
return await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(schema.users)
|
|
|
|
|
.where(inArray(schema.users.clerkId, clerkIds));
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-11 00:53:39 -07:00
|
|
|
export async function updateUser(
|
|
|
|
|
id: string,
|
|
|
|
|
data: Partial<NewUser>
|
|
|
|
|
): Promise<User> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const [user] = await db
|
|
|
|
|
.update(schema.users)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(eq(schema.users.id, id))
|
|
|
|
|
.returning();
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateUserByClerkId(
|
|
|
|
|
clerkId: string,
|
|
|
|
|
data: Partial<NewUser>
|
|
|
|
|
): Promise<User> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const [user] = await db
|
|
|
|
|
.update(schema.users)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(eq(schema.users.clerkId, clerkId))
|
|
|
|
|
.returning();
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function findOrCreateUser(clerkUser: {
|
|
|
|
|
id: string;
|
|
|
|
|
emailAddresses?: Array<{ emailAddress: string }>;
|
2025-10-14 21:20:58 -07:00
|
|
|
username?: string | null;
|
2025-10-11 00:53:39 -07:00
|
|
|
firstName?: string | null;
|
|
|
|
|
lastName?: string | null;
|
|
|
|
|
imageUrl?: string;
|
|
|
|
|
}): Promise<User> {
|
|
|
|
|
// Try to find existing user
|
|
|
|
|
const existingUser = await findUserByClerkId(clerkUser.id);
|
|
|
|
|
|
|
|
|
|
const email = clerkUser.emailAddresses?.[0]?.emailAddress;
|
|
|
|
|
if (!email) {
|
|
|
|
|
throw new Error("User must have an email address");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate display name: firstName lastName, or email username
|
|
|
|
|
let displayName = "";
|
|
|
|
|
if (clerkUser.firstName || clerkUser.lastName) {
|
|
|
|
|
displayName = [clerkUser.firstName, clerkUser.lastName]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ")
|
|
|
|
|
.trim();
|
|
|
|
|
} else {
|
|
|
|
|
// Use email username as fallback
|
|
|
|
|
displayName = email.split("@")[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existingUser) {
|
|
|
|
|
// Update user info in case it changed in Clerk
|
|
|
|
|
return await updateUserByClerkId(clerkUser.id, {
|
|
|
|
|
email,
|
2025-10-14 21:20:58 -07:00
|
|
|
username: clerkUser.username || undefined,
|
2025-10-11 00:53:39 -07:00
|
|
|
displayName,
|
|
|
|
|
firstName: clerkUser.firstName || undefined,
|
|
|
|
|
lastName: clerkUser.lastName || undefined,
|
|
|
|
|
imageUrl: clerkUser.imageUrl,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new user
|
|
|
|
|
return await createUser({
|
|
|
|
|
clerkId: clerkUser.id,
|
|
|
|
|
email,
|
2025-10-14 21:20:58 -07:00
|
|
|
username: clerkUser.username || undefined,
|
2025-10-11 00:53:39 -07:00
|
|
|
displayName,
|
|
|
|
|
firstName: clerkUser.firstName || undefined,
|
|
|
|
|
lastName: clerkUser.lastName || undefined,
|
|
|
|
|
imageUrl: clerkUser.imageUrl,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
|
|
|
const db = database();
|
|
|
|
|
await db.delete(schema.users).where(eq(schema.users.id, id));
|
|
|
|
|
}
|
2025-10-12 21:16:00 -07:00
|
|
|
|
|
|
|
|
export async function findAdmins(): Promise<User[]> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return await db.query.users.findMany({
|
|
|
|
|
where: eq(schema.users.isAdmin, true),
|
|
|
|
|
orderBy: (users, { asc }) => [asc(users.displayName)],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function isUserAdmin(userId: string): Promise<boolean> {
|
|
|
|
|
const user = await findUserById(userId);
|
|
|
|
|
return user?.isAdmin ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> {
|
2026-03-10 10:27:58 -07:00
|
|
|
if (process.env.NODE_ENV === "development" && process.env.DEV_ADMIN_CLERK_ID === clerkId) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-10-12 21:16:00 -07:00
|
|
|
const user = await findUserByClerkId(clerkId);
|
|
|
|
|
return user?.isAdmin ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
|
|
|
|
return await updateUser(userId, { isAdmin });
|
|
|
|
|
}
|
2025-10-21 22:15:15 -07:00
|
|
|
|
|
|
|
|
export async function findAllUsers(): Promise<User[]> {
|
|
|
|
|
const db = database();
|
|
|
|
|
return await db.query.users.findMany({
|
|
|
|
|
orderBy: (users, { asc }) => [asc(users.displayName)],
|
|
|
|
|
});
|
|
|
|
|
}
|