brackt/app/models/user.ts

164 lines
4.6 KiB
TypeScript
Raw Normal View History

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";
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;
}
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));
}
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 }>;
username?: string | null;
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,
username: clerkUser.username || undefined,
displayName,
firstName: clerkUser.firstName || undefined,
lastName: clerkUser.lastName || undefined,
imageUrl: clerkUser.imageUrl,
});
}
// Create new user
return await createUser({
clerkId: clerkUser.id,
email,
username: clerkUser.username || undefined,
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));
}
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> {
feat: progressive floor scoring for playoff brackets (#100) When a participant wins a bracket round, they immediately earn provisional "floor" points (the averaged minimum they'd receive if eliminated next round). These update as they advance and are replaced by finalized scores on elimination. Key changes: - Add `is_partial_score` column to `participant_results` (migration 0038) - `processPlayoffEvent`: assign provisional position 5 to non-scoring round winners; assign round-appropriate floors to scoring round winners via `getGuaranteedMinimumPosition`; add catch-all for unrecognized round names - `upsertParticipantResult`: guard against un-finalizing rows (never overwrite isPartialScore=false with true) - `calculateBracketPoints`: new function averaging tied bracket tiers (5-8 → 20 pts, 3-4 → avg, 1-2 solo); used in `calculateTeamScore` for playoff_bracket pattern (pattern-aware, doesn't affect F1/golf scoring) - `PlayoffBracket`: "In Contention" table for still-active participants; AFL double-chance fix (participants who won a later match excluded from earlier round's loser list); correct `nextRank` starting position - Server loader: batch owner DB queries (one query vs N+1); deduplicate participantPoints; use calculateBracketPoints for bracket point display - Clean up Phase/Q-number tracking comments throughout scoring-calculator.ts - 3 new tests for non-scoring round provisional floor behavior Also includes a dev admin bypass via DEV_ADMIN_CLERK_ID env var (separate change on this branch, not part of floor scoring feature). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:27:58 -07:00
if (process.env.NODE_ENV === "development" && process.env.DEV_ADMIN_CLERK_ID === clerkId) {
return true;
}
const user = await findUserByClerkId(clerkId);
return user?.isAdmin ?? false;
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
return await updateUser(userId, { isAdmin });
}
export async function findAllUsers(): Promise<User[]> {
const db = database();
return await db.query.users.findMany({
orderBy: (users, { asc }) => [asc(users.displayName)],
});
}