import { MembershipRole, Prisma } from "@prisma/client"; import { randomBytes } from "crypto"; import { z } from "zod"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import { sendTeamInviteEmail } from "@calcom/emails"; import { cancelTeamSubscriptionFromStripe, purchaseTeamSubscription, updateQuantitySubscriptionFromStripe, } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTeamWithMembers, isTeamAdmin, isTeamMember, isTeamOwner } from "@calcom/lib/server/queries/teams"; import slugify from "@calcom/lib/slugify"; import { closeComDeleteTeam, closeComDeleteTeamMembership, closeComUpdateTeam, closeComUpsertTeamUser, } from "@calcom/lib/sync/SyncServiceManager"; import { availabilityUserSelect } from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; import { authedProcedure, router } from "../../trpc"; const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); export const viewerTeamsRouter = router({ // Retrieves team by id get: authedProcedure .input( z.object({ teamId: z.number(), }) ) .query(async ({ ctx, input }) => { const team = await getTeamWithMembers(input.teamId, undefined, ctx.user.id); if (!team) { throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); } const membership = team?.members.find((membership) => membership.id === ctx.user.id); return { ...team, safeBio: markdownToSafeHTML(team.bio), membership: { role: membership?.role as MembershipRole, accepted: membership?.accepted, }, }; }), // Returns teams I a member of list: authedProcedure.query(async ({ ctx }) => { const memberships = await ctx.prisma.membership.findMany({ where: { userId: ctx.user.id, }, include: { team: true, }, orderBy: { role: "desc" }, }); return memberships.map(({ team, ...membership }) => ({ role: membership.role, accepted: membership.accepted, ...team, })); }), create: authedProcedure .input( z.object({ name: z.string(), slug: z.string().transform((val) => slugify(val.trim())), logo: z .string() .optional() .nullable() .transform((v) => v || null), }) ) .mutation(async ({ ctx, input }) => { const { slug, name, logo } = input; const nameCollisions = await ctx.prisma.team.findFirst({ where: { slug: slug, }, }); if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." }); // Ensure that the user is not duplicating a requested team const duplicatedRequest = await ctx.prisma.team.findFirst({ where: { members: { some: { userId: ctx.user.id, }, }, metadata: { path: ["requestedSlug"], equals: slug, }, }, }); if (duplicatedRequest) { return duplicatedRequest; } const createTeam = await ctx.prisma.team.create({ data: { name, logo, members: { create: { userId: ctx.user.id, role: MembershipRole.OWNER, accepted: true, }, }, metadata: { requestedSlug: slug, }, ...(!IS_TEAM_BILLING_ENABLED && { slug }), }, }); // Sync Services: Close.com closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); return createTeam; }), // Allows team owner to update team metadata update: authedProcedure .input( z.object({ id: z.number(), bio: z.string().optional(), name: z.string().optional(), logo: z.string().optional(), slug: z.string().optional(), hideBranding: z.boolean().optional(), hideBookATeamMember: z.boolean().optional(), brandColor: z.string().optional(), darkBrandColor: z.string().optional(), theme: z.string().optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" }); if (input.slug) { const userConflict = await ctx.prisma.team.findMany({ where: { slug: input.slug, }, }); if (userConflict.some((t) => t.id !== input.id)) return; } const prevTeam = await ctx.prisma.team.findFirst({ where: { id: input.id, }, }); if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); const data: Prisma.TeamUpdateArgs["data"] = { name: input.name, logo: input.logo, bio: input.bio, hideBranding: input.hideBranding, hideBookATeamMember: input.hideBookATeamMember, brandColor: input.brandColor, darkBrandColor: input.darkBrandColor, theme: input.theme, }; if ( input.slug && IS_TEAM_BILLING_ENABLED && /** If the team doesn't have a slug we can assume that it hasn't been published yet. */ !prevTeam.slug ) { // Save it on the metadata so we can use it later data.metadata = { requestedSlug: input.slug, }; } else { data.slug = input.slug; // If we save slug, we don't need the requestedSlug anymore const metadataParse = teamMetadataSchema.safeParse(prevTeam.metadata); if (metadataParse.success) { const { requestedSlug: _, ...cleanMetadata } = metadataParse.data || {}; data.metadata = { ...cleanMetadata, }; } } const updatedTeam = await ctx.prisma.team.update({ where: { id: input.id }, data, }); // Sync Services: Close.com if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); }), delete: authedProcedure .input( z.object({ teamId: z.number(), }) ) .mutation(async ({ ctx, input }) => { if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId); // delete all memberships await ctx.prisma.membership.deleteMany({ where: { teamId: input.teamId, }, }); const deletedTeam = await ctx.prisma.team.delete({ where: { id: input.teamId, }, }); // Sync Services: Close.cm closeComDeleteTeam(deletedTeam); }), removeMember: authedProcedure .input( z.object({ teamId: z.number(), memberId: z.number(), }) ) .mutation(async ({ ctx, input }) => { const isAdmin = await isTeamAdmin(ctx.user?.id, input.teamId); if (!isAdmin && ctx.user?.id !== input.memberId) throw new TRPCError({ code: "UNAUTHORIZED" }); // Only a team owner can remove another team owner. if ( (await isTeamOwner(input.memberId, input.teamId)) && !(await isTeamOwner(ctx.user?.id, input.teamId)) ) throw new TRPCError({ code: "UNAUTHORIZED" }); if (ctx.user?.id === input.memberId && isAdmin) throw new TRPCError({ code: "FORBIDDEN", message: "You can not remove yourself from a team you own.", }); const membership = await ctx.prisma.membership.delete({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, include: { user: true, }, }); // Sync Services closeComDeleteTeamMembership(membership.user); if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); }), inviteMember: authedProcedure .input( z.object({ teamId: z.number(), usernameOrEmail: z.string().transform((usernameOrEmail) => usernameOrEmail.toLowerCase()), role: z.nativeEnum(MembershipRole), language: z.string(), sendEmailInvitation: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const translation = await getTranslation(input.language ?? "en", "common"); const team = await ctx.prisma.team.findFirst({ where: { id: input.teamId, }, }); if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" }); const invitee = await ctx.prisma.user.findFirst({ where: { OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }], }, }); if (!invitee) { // liberal email match if (!isEmail(input.usernameOrEmail)) throw new TRPCError({ code: "NOT_FOUND", message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`, }); // valid email given, create User and add to team await ctx.prisma.user.create({ data: { email: input.usernameOrEmail, invitedTo: input.teamId, teams: { create: { teamId: input.teamId, role: input.role as MembershipRole, }, }, }, }); const token: string = randomBytes(32).toString("hex"); await ctx.prisma.verificationToken.create({ data: { identifier: input.usernameOrEmail, token, expires: new Date(new Date().setHours(168)), // +1 week }, }); if (ctx?.user?.name && team?.name) { await sendTeamInviteEmail({ language: translation, from: ctx.user.name, to: input.usernameOrEmail, teamName: team.name, joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/teams`, isCalcomMember: false, }); } } else { // create provisional membership try { await ctx.prisma.membership.create({ data: { teamId: input.teamId, userId: invitee.id, role: input.role as MembershipRole, }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === "P2002") { throw new TRPCError({ code: "FORBIDDEN", message: "This user is a member of this team / has a pending invitation.", }); } } else throw e; } let sendTo = input.usernameOrEmail; if (!isEmail(input.usernameOrEmail)) { sendTo = invitee.email; } // inform user of membership by email if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { await sendTeamInviteEmail({ language: translation, from: ctx.user.name, to: sendTo, teamName: team.name, joinLink: WEBAPP_URL + "/settings/teams", isCalcomMember: true, }); } } if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); }), acceptOrLeave: authedProcedure .input( z.object({ teamId: z.number(), accept: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { if (input.accept) { const membership = await ctx.prisma.membership.update({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, data: { accepted: true, }, include: { team: true, }, }); closeComUpsertTeamUser(membership.team, ctx.user, membership.role); } else { try { //get team owner so we can alter their subscription seat count const teamOwner = await ctx.prisma.membership.findFirst({ where: { teamId: input.teamId, role: MembershipRole.OWNER }, include: { team: true }, }); const membership = await ctx.prisma.membership.delete({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, }); // Sync Services: Close.com if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); } catch (e) { console.log(e); } } }), changeMemberRole: authedProcedure .input( z.object({ teamId: z.number(), memberId: z.number(), role: z.nativeEnum(MembershipRole), }) ) .mutation(async ({ ctx, input }) => { if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); // Only owners can award owner role. if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const memberships = await ctx.prisma.membership.findMany({ where: { teamId: input.teamId, }, }); const targetMembership = memberships.find((m) => m.userId === input.memberId); const myMembership = memberships.find((m) => m.userId === ctx.user.id); const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER); if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) { throw new TRPCError({ code: "FORBIDDEN", message: "You can not change the role of an owner if you are an admin.", }); } if (!teamHasMoreThanOneOwner) { throw new TRPCError({ code: "FORBIDDEN", message: "You can not change the role of the only owner of a team.", }); } if ( myMembership?.role === MembershipRole.ADMIN && input.memberId === ctx.user.id && input.role !== MembershipRole.MEMBER ) { throw new TRPCError({ code: "FORBIDDEN", message: "You can not change yourself to a higher role.", }); } const membership = await ctx.prisma.membership.update({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, data: { role: input.role, }, include: { team: true, user: true, }, }); // Sync Services: Close.com closeComUpsertTeamUser(membership.team, membership.user, membership.role); }), getMemberAvailability: authedProcedure .input( z.object({ teamId: z.number(), memberId: z.number(), timezone: z.string(), dateFrom: z.string(), dateTo: z.string(), }) ) .query(async ({ ctx, input }) => { const team = await isTeamMember(ctx.user?.id, input.teamId); if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); // verify member is in team const members = await ctx.prisma.membership.findMany({ where: { teamId: input.teamId }, include: { user: { select: { ...availabilityUserSelect, }, }, }, }); const member = members?.find((m) => m.userId === input.memberId); if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); if (!member.user.username) throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" }); // get availability for this member return await getUserAvailability( { username: member.user.username, dateFrom: input.dateFrom, dateTo: input.dateTo, }, { user: member.user } ); }), getMembershipbyUser: authedProcedure .input( z.object({ teamId: z.number(), memberId: z.number(), }) ) .query(async ({ ctx, input }) => { if (ctx.user.id !== input.memberId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You cannot view memberships that are not your own.", }); } return await ctx.prisma.membership.findUnique({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId, }, }, }); }), updateMembership: authedProcedure .input( z.object({ teamId: z.number(), memberId: z.number(), disableImpersonation: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { if (ctx.user.id !== input.memberId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You cannot edit memberships that are not your own.", }); } return await ctx.prisma.membership.update({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId, }, }, data: { disableImpersonation: input.disableImpersonation, }, }); }), validateTeamSlug: authedProcedure .input( z.object({ slug: z.string(), }) ) .query(async ({ ctx, input }) => { const team = await ctx.prisma.team.findFirst({ where: { slug: input.slug, }, }); return !team; }), publish: authedProcedure .input( z.object({ teamId: z.number(), }) ) .mutation(async ({ ctx, input }) => { if (!(await isTeamAdmin(ctx.user.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); const { teamId: id } = input; const prevTeam = await ctx.prisma.team.findFirst({ where: { id }, include: { members: true } }); if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." }); const metadata = teamMetadataSchema.safeParse(prevTeam.metadata); if (!metadata.success) throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" }); // if payment needed, respond with checkout url if (IS_TEAM_BILLING_ENABLED) { const checkoutSession = await purchaseTeamSubscription({ teamId: prevTeam.id, seats: prevTeam.members.length, userId: ctx.user.id, }); if (!checkoutSession.url) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed retrieving a checkout session URL.", }); return { url: checkoutSession.url, message: "Payment required to publish team" }; } if (!metadata.data?.requestedSlug) { throw new TRPCError({ code: "BAD_REQUEST", message: "Can't publish team without `requestedSlug`" }); } const { requestedSlug, ...newMetadata } = metadata.data; let updatedTeam: Awaited>; try { updatedTeam = await ctx.prisma.team.update({ where: { id }, data: { slug: requestedSlug, metadata: { ...newMetadata }, }, }); } catch (error) { const { message } = getRequestedSlugError(error, requestedSlug); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); } // Sync Services: Close.com closeComUpdateTeam(prevTeam, updatedTeam); return { url: `${WEBAPP_URL}/settings/teams/${updatedTeam.id}/profile`, message: "Team published successfully", }; }), /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ getUpgradeable: authedProcedure.query(async ({ ctx }) => { if (!IS_TEAM_BILLING_ENABLED) return []; let { teams } = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, include: { teams: { where: { role: MembershipRole.OWNER }, include: { team: true } } }, }); /** We only need to return teams that don't have a `subscriptionId` on their metadata */ teams = teams.filter((m) => { const metadata = teamMetadataSchema.safeParse(m.team.metadata); if (metadata.success && metadata.data?.subscriptionId) return false; return true; }); return teams; }), listMembers: authedProcedure .input( z.object({ teamIds: z.number().array().optional(), }) ) .query(async ({ ctx, input }) => { const teams = await ctx.prisma.team.findMany({ where: { id: { in: input.teamIds, }, members: { some: { user: { id: ctx.user.id, }, accepted: true, }, }, }, select: { members: { select: { user: { select: { id: true, name: true, avatar: true, }, }, }, }, }, }); type UserMap = Record; // flattern users to be unique by id const users = teams .flatMap((t) => t.members) .reduce((acc, m) => (m.user.id in acc ? acc : { ...acc, [m.user.id]: m.user }), {} as UserMap); return Object.values(users); }), hasTeamPlan: authedProcedure.query(async ({ ctx }) => { const userId = ctx.user.id; const hasTeamPlan = await ctx.prisma.membership.findFirst({ where: { userId, team: { slug: { not: null, }, }, }, }); return { hasTeamPlan: !!hasTeamPlan }; }), listInvites: authedProcedure.query(async ({ ctx }) => { const userId = ctx.user.id; return await ctx.prisma.membership.findMany({ where: { user: { id: userId, }, accepted: false, }, }); }), });