import type { SQL } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; import { BetterAuthError } from "better-auth"; import { and, eq, or, sql } from "drizzle-orm"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; import { generateId, toUsername } from "@reactive-resume/utils/string"; interface ExistingOAuthUser { id: string; email: string; emailVerified: boolean; username: string; displayUsername: string; name: string; image: string | null; accountId?: string; } function lower(column: T): SQL { return sql`lower(${column})`; } async function findExistingUserByEmail(email: string): Promise { const normalizedEmail = email.trim().toLowerCase(); const [existingUser] = await db .select({ id: schema.user.id, email: schema.user.email, emailVerified: schema.user.emailVerified, username: schema.user.username, displayUsername: schema.user.displayUsername, name: schema.user.name, image: schema.user.image, }) .from(schema.user) .where(eq(lower(schema.user.email), normalizedEmail)) .limit(1); return existingUser; } async function findLegacyGithubUser( profile: OAuthProfile, context: OAuthMapperContext, ): Promise { const login = profile.login; if (!login) return; const normalizedLogin = toUsername(login); const [legacyAccount] = await db .select({ id: schema.user.id, accountId: schema.account.accountId, email: schema.user.email, emailVerified: schema.user.emailVerified, username: schema.user.username, displayUsername: schema.user.displayUsername, name: schema.user.name, image: schema.user.image, }) .from(schema.account) .innerJoin(schema.user, eq(schema.account.userId, schema.user.id)) .where( and( eq(schema.account.providerId, "github"), eq(lower(schema.user.email), context.email), or( eq(lower(schema.user.username), normalizedLogin), eq(schema.user.displayUsername, login), eq(lower(schema.user.displayUsername), normalizedLogin), ), ), ) .limit(1); if (legacyAccount?.email.trim().toLowerCase() !== context.email) return; return legacyAccount; } async function normalizeExistingUserEmail(userId: string, currentEmail: string, normalizedEmail: string) { if (currentEmail === normalizedEmail) return; await db.update(schema.user).set({ email: normalizedEmail }).where(eq(schema.user.id, userId)); } function getEmailLocalPart(email: string): string { return email.split("@", 1)[0] ?? ""; } function appendUsernameSuffix(base: string, suffix: string): string { const maxBaseLength = 64 - suffix.length; return `${base.slice(0, maxBaseLength)}${suffix}`; } async function isUsernameTaken(candidate: string): Promise { const normalizedCandidate = candidate.trim().toLowerCase(); const [existingUser] = await db .select({ id: schema.user.id }) .from(schema.user) .where( or( eq(lower(schema.user.username), normalizedCandidate), eq(lower(schema.user.displayUsername), normalizedCandidate), ), ) .limit(1); return Boolean(existingUser); } async function allocateUniqueUsername(email: string, preferredUsername?: string | null): Promise { const emailLocalPart = getEmailLocalPart(email); const preferred = preferredUsername ? toUsername(preferredUsername) : ""; const normalizedEmailLocalPart = toUsername(emailLocalPart); const baseUsername = preferred || normalizedEmailLocalPart || "user"; if (!(await isUsernameTaken(baseUsername))) return baseUsername; const suffixedUsername = await findAvailableUsernameSuffix(baseUsername); if (suffixedUsername) return suffixedUsername; return appendUsernameSuffix(baseUsername, `-${generateId().slice(0, 8).toLowerCase()}`); } async function findAvailableUsernameSuffix(baseUsername: string, index = 1): Promise { if (index > 999) return null; const candidate = appendUsernameSuffix(baseUsername, `-${index}`); if (!(await isUsernameTaken(candidate))) return candidate; return findAvailableUsernameSuffix(baseUsername, index + 1); } interface OAuthProfile { email?: string | null; id?: string | number | null; name?: string | null; picture?: string | null; image?: string | null; avatar_url?: string | null; login?: string | null; preferred_username?: string | null; } interface OAuthMapperContext { email: string; emailLocalPart: string; } interface OAuthMapperOptions { providerName: string; findExistingUser?: (profile: TProfile, context: OAuthMapperContext) => Promise; getPreferredUsername?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null; getName?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null; getImage?: (profile: TProfile) => string | undefined | null; } export function createProfileMapper({ providerName, findExistingUser, getPreferredUsername, getName, getImage, }: OAuthMapperOptions) { return async (profile: TProfile) => { if (!profile.email) { throw new BetterAuthError( `${providerName} provider did not return an email address. This is required for user creation.`, { cause: "EMAIL_REQUIRED" }, ); } const email = profile.email.trim().toLowerCase(); const emailLocalPart = getEmailLocalPart(email); const context = { email, emailLocalPart }; const existingUser = (await findExistingUser?.(profile, context)) ?? (await findExistingUserByEmail(email)); const image = getImage?.(profile) ?? undefined; if (existingUser) { const existingEmail = existingUser.email.trim().toLowerCase(); await normalizeExistingUserEmail(existingUser.id, existingUser.email, existingEmail); return { ...(existingUser.accountId ? { id: existingUser.accountId } : {}), name: existingUser.name, email: existingEmail, image: image ?? existingUser.image, username: existingUser.username, displayUsername: existingUser.displayUsername, emailVerified: existingUser.emailVerified, }; } const preferredUsername = getPreferredUsername?.(profile, context); const username = await allocateUniqueUsername(email, preferredUsername); const mappedName = getName?.(profile, context)?.trim(); return { name: mappedName || username || emailLocalPart, email, image, username, displayUsername: username, emailVerified: true, }; }; } export function createGithubProfileMapper() { return createProfileMapper({ providerName: "GitHub", findExistingUser: findLegacyGithubUser, getPreferredUsername: (profile, context) => profile.login ?? context.emailLocalPart, getName: (profile, context) => profile.name ?? profile.login ?? context.emailLocalPart, getImage: (profile) => profile.avatar_url, }); }