import { MembershipRole, UserPlan } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { randomBytes } from "crypto"; import { z } from "zod"; import { addSeat, removeSeat, getTeamSeatStats, downgradeTeamMembers, upgradeTeam, ensureSubscriptionQuantityCorrectness, } from "@ee/lib/stripe/team-billing"; import { BASE_URL } from "@lib/config/constants"; import { HOSTED_CAL_FEATURES } from "@lib/config/constants"; import { sendTeamInviteEmail } from "@lib/emails/email-manager"; import { TeamInvite } from "@lib/emails/templates/team-invite-email"; import { getUserAvailability } from "@lib/queries/availability"; import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams"; import slugify from "@lib/slugify"; import { createProtectedRouter } from "@server/createRouter"; import { getTranslation } from "@server/lib/i18n"; import { TRPCError } from "@trpc/server"; export const viewerTeamsRouter = createProtectedRouter() // Retrieves team by id .query("get", { input: z.object({ teamId: z.number(), }), async resolve({ ctx, input }) { const team = await getTeamWithMembers(input.teamId); if (!team?.members.find((m) => m.id === ctx.user.id)) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." }); } const membership = team?.members.find((membership) => membership.id === ctx.user.id); return { ...team, membership: { role: membership?.role as MembershipRole, isMissingSeat: membership?.plan === UserPlan.FREE, }, requiresUpgrade: HOSTED_CAL_FEATURES ? !!team.members.find((m) => m.plan !== UserPlan.PRO) : false, }; }, }) // Returns teams I a member of .query("list", { async resolve({ ctx }) { const memberships = await ctx.prisma.membership.findMany({ where: { userId: ctx.user.id, }, orderBy: { role: "desc" }, }); const teams = await ctx.prisma.team.findMany({ where: { id: { in: memberships.map((membership) => membership.teamId), }, }, }); return memberships.map((membership) => ({ role: membership.role, accepted: membership.role === "OWNER" ? true : membership.accepted, ...teams.find((team) => team.id === membership.teamId), })); }, }) .mutation("create", { input: z.object({ name: z.string(), }), async resolve({ ctx, input }) { if (ctx.user.plan === "FREE") { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a pro user." }); } const slug = slugify(input.name); const nameCollisions = await ctx.prisma.team.count({ where: { OR: [{ name: input.name }, { slug: slug }], }, }); if (nameCollisions > 0) throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." }); const createTeam = await ctx.prisma.team.create({ data: { name: input.name, slug: slug, }, }); await ctx.prisma.membership.create({ data: { teamId: createTeam.id, userId: ctx.user.id, role: "OWNER", accepted: true, }, }); }, }) // Allows team owner to update team metadata .mutation("update", { 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(), }), async resolve({ 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; } await ctx.prisma.team.update({ where: { id: input.id, }, data: { name: input.name, slug: input.slug, logo: input.logo, bio: input.bio, hideBranding: input.hideBranding, }, }); }, }) .mutation("delete", { input: z.object({ teamId: z.number(), }), async resolve({ ctx, input }) { if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); await downgradeTeamMembers(input.teamId); // delete all memberships await ctx.prisma.membership.deleteMany({ where: { teamId: input.teamId, }, }); await ctx.prisma.team.delete({ where: { id: input.teamId, }, }); }, }) // Allows owner to remove member from team .mutation("removeMember", { input: z.object({ teamId: z.number(), memberId: z.number(), }), async resolve({ ctx, input }) { if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" }); if (ctx.user?.id === input.memberId) throw new TRPCError({ code: "FORBIDDEN", message: "You can not remove yourself from a team you own.", }); await ctx.prisma.membership.delete({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, }); if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId); }, }) .mutation("inviteMember", { input: z.object({ teamId: z.number(), usernameOrEmail: z.string(), role: z.nativeEnum(MembershipRole), language: z.string(), sendEmailInvitation: z.boolean(), }), async resolve({ ctx, input }) { if (!(await isTeamAdmin(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 }], }, }); let inviteeUserId: number | undefined = invitee?.id; if (!invitee) { // liberal email match const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str); 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 const user = await ctx.prisma.user.create({ data: { email: input.usernameOrEmail, invitedTo: input.teamId, teams: { create: { teamId: input.teamId, role: input.role as MembershipRole, }, }, }, }); inviteeUserId = user.id; const token: string = randomBytes(32).toString("hex"); await ctx.prisma.verificationRequest.create({ data: { identifier: input.usernameOrEmail, token, expires: new Date(new Date().setHours(168)), // +1 week }, }); if (ctx?.user?.name && team?.name) { const teamInviteEvent: TeamInvite = { language: translation, from: ctx.user.name, to: input.usernameOrEmail, teamName: team.name, joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`, }; await sendTeamInviteEmail(teamInviteEvent); } } 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; } // inform user of membership by email if (input.sendEmailInvitation && ctx?.user?.name && team?.name) { const teamInviteEvent: TeamInvite = { language: translation, from: ctx.user.name, to: input.usernameOrEmail, teamName: team.name, joinLink: BASE_URL + "/settings/teams", }; await sendTeamInviteEmail(teamInviteEvent); } } try { if (HOSTED_CAL_FEATURES) await addSeat(ctx.user.id, team.id, inviteeUserId); } catch (e) { console.log(e); } }, }) .mutation("acceptOrLeave", { input: z.object({ teamId: z.number(), accept: z.boolean(), }), async resolve({ ctx, input }) { if (input.accept) { await ctx.prisma.membership.update({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, data: { accepted: true, }, }); } 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 }, }); // TODO: disable if not hosted by Cal if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id); } catch (e) { console.log(e); } await ctx.prisma.membership.delete({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, }); } }, }) .mutation("changeMemberRole", { input: z.object({ teamId: z.number(), memberId: z.number(), role: z.nativeEnum(MembershipRole), }), async resolve({ ctx, input }) { if (!(await isTeamAdmin(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) { throw new TRPCError({ code: "FORBIDDEN", message: "You can not change yourself to a higher role.", }); } await ctx.prisma.membership.update({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, data: { role: input.role, }, }); }, }) .query("getMemberAvailability", { input: z.object({ teamId: z.number(), memberId: z.number(), timezone: z.string(), dateFrom: z.string(), dateTo: z.string(), }), async resolve({ ctx, input }) { const team = await isTeamAdmin(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: true }, }); 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, timezone: input.timezone, dateFrom: input.dateFrom, dateTo: input.dateTo, }); }, }) .mutation("upgradeTeam", { input: z.object({ teamId: z.number(), }), async resolve({ ctx, input }) { if (!HOSTED_CAL_FEATURES) throw new TRPCError({ code: "FORBIDDEN", message: "Team billing is not enabled" }); return await upgradeTeam(ctx.user.id, input.teamId); }, }) .query("getTeamSeats", { input: z.object({ teamId: z.number(), }), async resolve({ input }) { return await getTeamSeatStats(input.teamId); }, }) .mutation("ensureSubscriptionQuantityCorrectness", { input: z.object({ teamId: z.number(), }), async resolve({ ctx, input }) { return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId); }, });