448 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			448 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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);
 | |
|     },
 | |
|   });
 | 
