import { type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; import { rbac } from "~/services/rbac.server"; const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); export async function getTeamMembersAndInvites({ userId, organizationId, }: { userId: string; organizationId: string; }) { const org = await prisma.organization.findFirst({ where: { id: organizationId, members: { some: { userId } } }, select: { members: { select: { id: true, role: true, user: { select: { id: true, name: true, email: true, avatarUrl: true, }, }, }, }, invites: { select: { id: true, email: true, updatedAt: true, inviter: { select: { id: true, name: true, email: true, avatarUrl: true, }, }, }, }, }, }); if (!org) { return null; } return { members: org.members, invites: org.invites }; } export async function removeTeamMember({ userId, slug, memberId, }: { userId: string; slug: string; memberId: string; }) { const org = await prisma.organization.findFirst({ where: { slug, members: { some: { userId } } }, }); if (!org) { throw new Error("User does not have access to this organization"); } // Scope the target to this org. A member id is a globally unique key, so // deleting by id alone would remove members of other orgs; bind it to the // resolved org and reject a foreign id. const member = await prisma.orgMember.findFirst({ where: { id: memberId, organizationId: org.id }, include: { organization: true, user: true, }, }); if (!member) { throw new Error("Member not found in this organization"); } await prisma.orgMember.delete({ where: { id: member.id } }); return member; } export async function inviteMembers({ slug, emails, userId, rbacRoleId, }: { slug: string; emails: string[]; userId: string; /** * Optional RBAC role to attach to the invite. When set, accepted * invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember * is created. * * `OrgMemberInvite.role` is still set if the plugin isn't installed. */ rbacRoleId?: string | null; }) { const org = await prisma.organization.findFirst({ where: { slug, members: { some: { userId } } }, }); if (!org) { throw new Error("User does not have access to this organization"); } const invites = [...new Set(emails)].map( (email) => ({ email, token: tokenGenerator(), organizationId: org.id, inviterId: userId, role: "MEMBER", rbacRoleId: rbacRoleId ?? null, }) satisfies Prisma.OrgMemberInviteCreateManyInput ); await prisma.orgMemberInvite.createMany({ data: invites, }); return await prisma.orgMemberInvite.findMany({ where: { organizationId: org.id, inviterId: userId, email: { in: emails, }, }, include: { organization: true, inviter: true, }, }); } export async function getInviteFromToken({ token }: { token: string }) { return await prisma.orgMemberInvite.findFirst({ where: { token, }, include: { organization: true, inviter: true, }, }); } export async function getUsersInvites({ email }: { email: string }) { return await prisma.orgMemberInvite.findMany({ where: { email, organization: { deletedAt: null, }, }, include: { organization: true, inviter: true, }, }); } export async function acceptInvite({ user, inviteId, }: { user: { id: string; email: string }; inviteId: string; }) { const result = await prisma.$transaction(async (tx) => { // 1. Delete the invite and get the invite details const invite = await tx.orgMemberInvite.delete({ where: { id: inviteId, email: user.email, }, include: { organization: { include: { projects: true, }, }, }, }); // 2. Join the organization const member = await tx.orgMember.create({ data: { organizationId: invite.organizationId, userId: user.id, role: invite.role, }, }); // 3. Create an environment for each project for (const project of invite.organization.projects) { await createEnvironment({ organization: invite.organization, project, type: "DEVELOPMENT", // We set this true but no backfill (yet!?) so never used // for dev environments isBranchableEnvironment: true, member, prismaClient: tx, }); } // 4. Check for other invites const remainingInvites = await tx.orgMemberInvite.findMany({ where: { email: user.email, }, }); return { remainingInvites, organization: invite.organization, inviteRole: invite.role, rbacRoleId: invite.rbacRoleId, }; }); // If the invite carried an explicit RBAC role, assign it. Best-effort: the // invite is already consumed and membership created above, so a failure here // — a returned {ok:false} or a thrown error from the plugin — must not block // joining the org. Swallow and log either way; without the catch a plugin // throw escapes and turns the whole invite-accept into a 400. if (result.rbacRoleId) { try { const roleResult = await rbac.setUserRole({ userId: user.id, organizationId: result.organization.id, roleId: result.rbacRoleId, }); if (!roleResult.ok) { logger.error("acceptInvite: skipped RBAC role assignment", { organizationId: result.organization.id, userId: user.id, rbacRoleId: result.rbacRoleId, reason: roleResult.error, }); } } catch (error) { logger.error("acceptInvite: RBAC role assignment threw", { organizationId: result.organization.id, userId: user.id, rbacRoleId: result.rbacRoleId, error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error), }); } } return { remainingInvites: result.remainingInvites, organization: result.organization }; } export async function declineInvite({ user, inviteId, }: { user: { id: string; email: string }; inviteId: string; }) { return await prisma.$transaction(async (tx) => { //1. delete invite const declinedInvite = await prisma.orgMemberInvite.delete({ where: { id: inviteId, email: user.email, }, include: { organization: true, }, }); //2. check for other invites const remainingInvites = await prisma.orgMemberInvite.findMany({ where: { email: user.email, }, }); return { remainingInvites, organization: declinedInvite.organization }; }); } export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) { return await prisma.orgMemberInvite.update({ where: { id: inviteId, inviterId: userId, }, data: { updatedAt: new Date(), }, include: { inviter: true, organization: true, }, }); } export async function revokeInvite({ userId, orgSlug, inviteId, }: { userId: string; orgSlug: string; inviteId: string; }) { const invite = await prisma.orgMemberInvite.findFirst({ where: { id: inviteId, organization: { slug: orgSlug, members: { some: { userId, }, }, }, }, select: { id: true, email: true, organization: true, }, }); if (!invite) { throw new Error("Invite not found"); } await prisma.orgMemberInvite.delete({ where: { id: invite.id, }, }); return { email: invite.email, organization: invite.organization }; }