451 lines
14 KiB
TypeScript
451 lines
14 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 "@calcom/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.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" });
|
|
|
|
if (process.env.STRIPE_PRIVATE_KEY) {
|
|
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.verificationToken.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);
|
|
},
|
|
});
|