diff --git a/lib/validations/team.ts b/lib/validations/team.ts index 352b2ff431..e5f8e3cbfa 100644 --- a/lib/validations/team.ts +++ b/lib/validations/team.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { _TeamModel as Team } from "@calcom/prisma/zod"; -export const schemaTeamBaseBodyParams = Team.omit({ id: true }).partial({ hideBranding: true }); +export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({ + hideBranding: true, + metadata: true, +}); const schemaTeamRequiredParams = z.object({}); diff --git a/pages/api/attendees/_post.ts b/pages/api/attendees/_post.ts index 106007019d..4d74928eb9 100644 --- a/pages/api/attendees/_post.ts +++ b/pages/api/attendees/_post.ts @@ -1,6 +1,6 @@ -import { HttpError } from "@/../../packages/lib/http-error"; import type { NextApiRequest } from "next"; +import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "@lib/validations/attendee"; diff --git a/pages/api/event-types/_post.ts b/pages/api/event-types/_post.ts index 75faa6d5c0..640b0b0b31 100644 --- a/pages/api/event-types/_post.ts +++ b/pages/api/event-types/_post.ts @@ -1,7 +1,7 @@ -import { HttpError } from "@/../../packages/lib/http-error"; import type { Prisma } from "@prisma/client"; import type { NextApiRequest } from "next"; +import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "@lib/validations/event-type"; diff --git a/pages/api/teams/[teamId]/_auth-middleware.ts b/pages/api/teams/[teamId]/_auth-middleware.ts index 347c3e3075..13e906e5f9 100644 --- a/pages/api/teams/[teamId]/_auth-middleware.ts +++ b/pages/api/teams/[teamId]/_auth-middleware.ts @@ -1,5 +1,9 @@ +import type { Prisma } from "@prisma/client"; +import { MembershipRole } from "@prisma/client"; import type { NextApiRequest } from "next"; +import { HttpError } from "@calcom/lib/http-error"; + import { schemaQueryTeamId } from "@lib/validations/shared/queryTeamId"; async function authMiddleware(req: NextApiRequest) { @@ -13,4 +17,18 @@ async function authMiddleware(req: NextApiRequest) { }); } +export async function checkPermissions( + req: NextApiRequest, + role: Prisma.MembershipWhereInput["role"] = MembershipRole.OWNER +) { + const { userId, prisma, isAdmin } = req; + const { teamId } = schemaQueryTeamId.parse(req.query); + const args: Prisma.TeamFindFirstArgs = { where: { id: teamId } }; + /** If not ADMIN then we check if the actual user belongs to team and matches the required role */ + if (!isAdmin) args.where = { ...args.where, members: { some: { userId, role } } }; + const team = await prisma.team.findFirst(args); + if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` }); + return team; +} + export default authMiddleware; diff --git a/pages/api/teams/[teamId]/_delete.ts b/pages/api/teams/[teamId]/_delete.ts index 9b8e723a9c..c9f4443809 100644 --- a/pages/api/teams/[teamId]/_delete.ts +++ b/pages/api/teams/[teamId]/_delete.ts @@ -1,10 +1,11 @@ import type { NextApiRequest } from "next"; -import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { schemaQueryTeamId } from "@lib/validations/shared/queryTeamId"; +import { checkPermissions } from "./_auth-middleware"; + /** * @swagger * /users/{teamId}: @@ -36,15 +37,4 @@ export async function deleteHandler(req: NextApiRequest) { return { message: `Team with id: ${teamId} deleted successfully` }; } -async function checkPermissions(req: NextApiRequest) { - const { userId, prisma, isAdmin } = req; - const { teamId } = schemaQueryTeamId.parse(req.query); - if (isAdmin) return; - /** Only OWNERS can delete teams */ - const _team = await prisma.team.findFirst({ - where: { id: teamId, members: { some: { userId, role: "OWNER" } } }, - }); - if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER required" }); -} - export default defaultResponder(deleteHandler); diff --git a/pages/api/teams/[teamId]/_patch.ts b/pages/api/teams/[teamId]/_patch.ts index 701464223b..0031677174 100644 --- a/pages/api/teams/[teamId]/_patch.ts +++ b/pages/api/teams/[teamId]/_patch.ts @@ -1,11 +1,17 @@ import type { NextApiRequest } from "next"; +import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { schemaQueryTeamId } from "@lib/validations/shared/queryTeamId"; import { schemaTeamReadPublic, schemaTeamUpdateBodyParams } from "@lib/validations/team"; +import { TRPCError } from "@trpc/server"; + +import { Prisma } from ".prisma/client"; + /** * @swagger * /teams/{teamId}: @@ -35,9 +41,32 @@ export async function patchHandler(req: NextApiRequest) { const { teamId } = schemaQueryTeamId.parse(req.query); /** Only OWNERS and ADMINS can edit teams */ const _team = await prisma.team.findFirst({ + include: { members: true }, where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, }); if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); + let paymentUrl; + if (_team.slug === null && data.slug) { + data.metadata = { + ...(_team.metadata as Prisma.JsonObject), + requestedSlug: data.slug, + }; + delete data.slug; + if (IS_TEAM_BILLING_ENABLED) { + const checkoutSession = await purchaseTeamSubscription({ + teamId: _team.id, + seats: _team.members.length, + userId, + }); + if (!checkoutSession.url) + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed retrieving a checkout session URL.", + }); + paymentUrl = checkoutSession.url; + } + } + // TODO: Perhaps there is a better fix for this? const cloneData: typeof data & { metadata: NonNullable | undefined; @@ -46,7 +75,14 @@ export async function patchHandler(req: NextApiRequest) { metadata: data.metadata === null ? {} : data.metadata || undefined, }; const team = await prisma.team.update({ where: { id: teamId }, data: cloneData }); - return { team: schemaTeamReadPublic.parse(team) }; + const result = { + team: schemaTeamReadPublic.parse(team), + paymentUrl, + }; + if (!paymentUrl) { + delete result.paymentUrl; + } + return result; } export default defaultResponder(patchHandler); diff --git a/pages/api/teams/[teamId]/publish.ts b/pages/api/teams/[teamId]/publish.ts new file mode 100644 index 0000000000..f99762fe52 --- /dev/null +++ b/pages/api/teams/[teamId]/publish.ts @@ -0,0 +1,57 @@ +import { MembershipRole, UserPermissionRole } from "@prisma/client"; +import { NextApiRequest, NextApiResponse } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import { createContext } from "@calcom/trpc/server/createContext"; +import { viewerRouter } from "@calcom/trpc/server/routers/viewer"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; +import { schemaQueryTeamId } from "@lib/validations/shared/queryTeamId"; + +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; + +import authMiddleware, { checkPermissions } from "./_auth-middleware"; + +const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => { + const { isAdmin } = req; + await checkPermissions(req, { in: [MembershipRole.OWNER, MembershipRole.ADMIN] }); + + /** We shape the session as required by tRPC rounter */ + async function sessionGetter() { + return { + user: { + id: req.userId, + username: "" /* Not used in this context */, + role: isAdmin ? UserPermissionRole.ADMIN : UserPermissionRole.USER, + }, + hasValidLicense: true, + expires: "" /* Not used in this context */, + }; + } + + /** @see https://trpc.io/docs/server-side-calls */ + const ctx = await createContext({ req, res }, sessionGetter); + const caller = viewerRouter.createCaller(ctx); + try { + const { teamId } = schemaQueryTeamId.parse(req.query); + const success_url = req.url?.replace("/publish", ""); + return await caller.teams.publish({ teamId, success_url }); + } catch (cause) { + if (cause instanceof TRPCError) { + const statusCode = getHTTPStatusCodeFromError(cause); + throw new HttpError({ statusCode, message: cause.message }); + } + throw cause; + } +}; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }), + })(req, res); + }) +); diff --git a/pages/api/teams/_post.ts b/pages/api/teams/_post.ts index 41207255ce..5ec2a0d032 100644 --- a/pages/api/teams/_post.ts +++ b/pages/api/teams/_post.ts @@ -1,5 +1,8 @@ +import { MembershipRole } from "@prisma/client"; import type { NextApiRequest } from "next"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { schemaMembershipPublic } from "@lib/validations/membership"; @@ -24,6 +27,23 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "@lib/validations/tea async function postHandler(req: NextApiRequest) { const { prisma, body, userId } = req; const data = schemaTeamBodyParams.parse(body); + + if (data.slug) { + const alreadyExist = await prisma.team.findFirst({ + where: { + slug: data.slug, + }, + }); + if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + if (IS_TEAM_BILLING_ENABLED) { + // Setting slug in metadata, so it can be published later + data.metadata = { + requestedSlug: data.slug, + }; + delete data.slug; + } + } + // TODO: Perhaps there is a better fix for this? const cloneData: typeof data & { metadata: NonNullable | undefined; @@ -34,9 +54,10 @@ async function postHandler(req: NextApiRequest) { const team = await prisma.team.create({ data: { ...cloneData, + createdAt: new Date(), members: { // We're also creating the relation membership of team ownership in this call. - create: { userId, role: "OWNER", accepted: true }, + create: { userId, role: MembershipRole.OWNER, accepted: true }, }, }, include: { members: true },