Files
triggerdotdev--trigger.dev/apps/webapp/app/models/member.server.ts
Chris Arderne 4fde283e76 chore: format and lint webapp also (#4056)
#3977 added formatting and linting everywhere else.

This extends it to the webapp.
2026-06-26 13:02:53 +01:00

363 lines
8.2 KiB
TypeScript

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 };
}