import type { Prisma, User } from "@trigger.dev/database"; import type { GitHubProfile } from "remix-auth-github"; import type { GoogleProfile } from "remix-auth-google"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import type { DashboardPreferences } from "~/services/dashboardPreferences.server"; import { getDashboardPreferences } from "~/services/dashboardPreferences.server"; export type { User } from "@trigger.dev/database"; import { assertEmailAllowed } from "~/utils/email"; import { emailMatchesPattern } from "~/utils/emailPattern"; import { logger } from "~/services/logger.server"; type FindOrCreateMagicLink = { authenticationMethod: "MAGIC_LINK"; email: string; }; type FindOrCreateGithub = { authenticationMethod: "GITHUB"; email: User["email"]; authenticationProfile: GitHubProfile; authenticationExtraParams: Record; }; type FindOrCreateGoogle = { authenticationMethod: "GOOGLE"; email: User["email"]; authenticationProfile: GoogleProfile; authenticationExtraParams: Record; }; type FindOrCreateSso = { authenticationMethod: "SSO"; email: User["email"]; firstName: string | null; lastName: string | null; }; type FindOrCreateUser = | FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle | FindOrCreateSso; type LoggedInUser = { user: User; isNewUser: boolean; }; export async function findOrCreateUser(input: FindOrCreateUser): Promise { switch (input.authenticationMethod) { case "GITHUB": { return findOrCreateGithubUser(input); } case "MAGIC_LINK": { return findOrCreateMagicLinkUser(input); } case "GOOGLE": { return findOrCreateGoogleUser(input); } case "SSO": { return findOrCreateSsoUser(input); } } } export async function findOrCreateMagicLinkUser({ email, }: FindOrCreateMagicLink): Promise { assertEmailAllowed(email); const existingUser = await prisma.user.findFirst({ where: { email, }, }); const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false; const user = await prisma.user.upsert({ where: { email, }, update: { email, }, create: { email, authenticationMethod: "MAGIC_LINK", admin: makeAdmin, // only on create, to prevent automatically removing existing admins }, }); return { user, isNewUser: !existingUser, }; } export async function findOrCreateGithubUser({ email, authenticationProfile, authenticationExtraParams, }: FindOrCreateGithub): Promise { assertEmailAllowed(email); const name = authenticationProfile._json.name; let avatarUrl: string | undefined = undefined; if (authenticationProfile.photos[0]) { avatarUrl = authenticationProfile.photos[0].value; } const displayName = authenticationProfile.displayName; const authProfile = authenticationProfile ? (authenticationProfile as unknown as Prisma.JsonObject) : undefined; const authExtraParams = authenticationExtraParams ? (authenticationExtraParams as unknown as Prisma.JsonObject) : undefined; const authIdentifier = `github:${authenticationProfile.id}`; const existingUser = await prisma.user.findUnique({ where: { authIdentifier, }, }); const existingEmailUser = await prisma.user.findUnique({ where: { email, }, }); if (existingEmailUser && !existingUser) { const user = await prisma.user.update({ where: { email, }, data: { authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, avatarUrl, authIdentifier, }, }); return { user, isNewUser: false, }; } if (existingEmailUser && existingUser) { const user = await prisma.user.update({ where: { id: existingUser.id, }, data: {}, }); return { user, isNewUser: false, }; } const user = await prisma.user.upsert({ where: { authIdentifier, }, update: {}, create: { authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, name, avatarUrl, displayName, authIdentifier, email, authenticationMethod: "GITHUB", }, }); return { user, isNewUser: !existingUser, }; } export async function findOrCreateGoogleUser({ email, authenticationProfile, authenticationExtraParams, }: FindOrCreateGoogle): Promise { assertEmailAllowed(email); const name = authenticationProfile._json.name; let avatarUrl: string | undefined = undefined; if (authenticationProfile.photos[0]) { avatarUrl = authenticationProfile.photos[0].value; } const displayName = authenticationProfile.displayName; const authProfile = authenticationProfile ? (authenticationProfile as unknown as Prisma.JsonObject) : undefined; const authExtraParams = authenticationExtraParams ? (authenticationExtraParams as unknown as Prisma.JsonObject) : undefined; const authIdentifier = `google:${authenticationProfile.id}`; const existingUser = await prisma.user.findUnique({ where: { authIdentifier, }, }); const existingEmailUser = await prisma.user.findUnique({ where: { email, }, }); if (existingEmailUser && !existingUser) { // Link existing email account to Google auth, preserving original authenticationMethod const user = await prisma.user.update({ where: { email, }, data: { authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, avatarUrl, authIdentifier, }, }); return { user, isNewUser: false, }; } if (existingEmailUser && existingUser) { // Check if email user and auth user are the same if (existingEmailUser.id !== existingUser.id) { // Different users: email is taken by one user, Google auth belongs to another logger.warn( `Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`, { email, existingEmailUserId: existingEmailUser.id, existingAuthUserId: existingUser.id, authIdentifier, } ); return { user: existingUser, isNewUser: false, }; } // Same user: update all profile fields const user = await prisma.user.update({ where: { id: existingUser.id, }, data: { email, displayName, name, avatarUrl, authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, }, }); return { user, isNewUser: false, }; } // When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated // It's not possible that the email is already taken by a different user because that would have been handled // by one of the if statements above. const user = await prisma.user.upsert({ where: { authIdentifier, }, update: { email, displayName, name, avatarUrl, authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, }, create: { authenticationProfile: authProfile, authenticationExtraParams: authExtraParams, name, avatarUrl, displayName, authIdentifier, email, authenticationMethod: "GOOGLE", }, }); return { user, isNewUser: !existingUser, }; } // Find an existing user by email (lowercased) or create a new one with the // SSO authentication method. Mirrors the magic-link upsert shape; the // callback route is responsible for normalising email before calling. // Plugin writes (linking the IdP identity row) happen via the SSO plugin // after this returns. export async function findOrCreateSsoUser({ email, firstName, lastName, }: FindOrCreateSso): Promise { // Validate the canonical value we actually look up and persist below — // validating raw `email` would let case/whitespace variants slip past // (or misapply) the allow-list policy. const normalised = email.toLowerCase().trim(); assertEmailAllowed(normalised); const existingUser = await prisma.user.findFirst({ where: { email: normalised } }); const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || null; const user = await prisma.user.upsert({ where: { email: normalised }, update: { // Existing magic-link / OAuth users keep their original // authenticationMethod; we only refresh name/displayName when the // user has nothing set yet so we don't clobber a customised display // name on every SSO login. ...(existingUser?.name ? {} : { name: fullName }), ...(existingUser?.displayName ? {} : { displayName: fullName }), }, create: { email: normalised, name: fullName, displayName: fullName, authenticationMethod: "SSO", }, }); return { user, isNewUser: !existingUser }; } export type UserWithDashboardPreferences = User & { dashboardPreferences: DashboardPreferences; }; export async function getUserById(id: User["id"]) { const user = await prisma.user.findUnique({ where: { id } }); if (!user) { return null; } const dashboardPreferences = getDashboardPreferences(user.dashboardPreferences); return { ...user, dashboardPreferences, }; } export async function getUserByEmail(email: User["email"]) { return prisma.user.findUnique({ where: { email } }); } export function updateUser({ id, name, email, marketingEmails, referralSource, onboardingData, }: Pick & { marketingEmails?: boolean; referralSource?: string; onboardingData?: Prisma.InputJsonValue; }) { return prisma.user.update({ where: { id }, data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true, }, }); } export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) { return prisma.user.update({ where: { id }, data: { invitationCode: { connect: { code: inviteCode, }, }, }, }); }