diff --git a/lib/validations/membership.ts b/lib/validations/membership.ts index d58eea2c33..27206d43b9 100644 --- a/lib/validations/membership.ts +++ b/lib/validations/membership.ts @@ -1,14 +1,65 @@ +import { stringOrNumber } from "@calcom/prisma/zod-utils"; +import { MembershipRole } from "@prisma/client"; import { z } from "zod"; -import { _MembershipModel as Membership } from "@calcom/prisma/zod"; +import { _MembershipModel as Membership, _TeamModel } from "@calcom/prisma/zod"; + +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; +import { schemaQueryIdParseInt } from "@lib/validations/shared/queryIdTransformParseInt"; export const schemaMembershipBaseBodyParams = Membership.omit({}); + const schemaMembershipRequiredParams = z.object({ teamId: z.number(), }); +export const membershipCreateBodySchema = Membership.partial({ + accepted: true, + role: true, + disableImpersonation: true, +}).transform((v) => ({ + accepted: false, + role: MembershipRole.MEMBER, + disableImpersonation: false, + ...v, +})); + +export const membershipEditBodySchema = Membership.omit({ + /** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */ + teamId: true, + userId: true, +}) + .partial({ + accepted: true, + role: true, + disableImpersonation: true, + }) + .strict(); + export const schemaMembershipBodyParams = schemaMembershipBaseBodyParams.merge( schemaMembershipRequiredParams ); -export const schemaMembershipPublic = Membership.omit({}); +export const schemaMembershipPublic = Membership.merge(z.object({ team: _TeamModel }).partial()); + +/** We extract userId and teamId from compound ID string */ +export const membershipIdSchema = schemaQueryIdAsString + // So we can query additional team data in memberships + .merge(z.object({ teamId: z.union([stringOrNumber, z.array(stringOrNumber)]) }).partial()) + .transform((v, ctx) => { + const [userIdStr, teamIdStr] = v.id.split("_"); + const userIdInt = schemaQueryIdParseInt.safeParse({ id: userIdStr }); + const teamIdInt = schemaQueryIdParseInt.safeParse({ id: teamIdStr }); + if (!userIdInt.success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "userId is not a number" }); + return z.NEVER; + } + if (!teamIdInt.success) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "teamId is not a number " }); + return z.NEVER; + } + return { + userId: userIdInt.data.id, + teamId: teamIdInt.data.id, + }; + }); diff --git a/lib/validations/shared/queryUserId.ts b/lib/validations/shared/queryUserId.ts index 94de53a214..88e2c7e8de 100644 --- a/lib/validations/shared/queryUserId.ts +++ b/lib/validations/shared/queryUserId.ts @@ -17,6 +17,10 @@ export const schemaQuerySingleOrMultipleUserIds = z.object({ userId: z.union([stringOrNumber, z.array(stringOrNumber)]), }); +export const schemaQuerySingleOrMultipleTeamIds = z.object({ + teamId: z.union([stringOrNumber, z.array(stringOrNumber)]), +}); + export const withValidQueryUserId = withValidation({ schema: schemaQueryUserId, type: "Zod", diff --git a/pages/api/memberships/[id].ts b/pages/api/memberships/[id].ts deleted file mode 100644 index b6050ebbe9..0000000000 --- a/pages/api/memberships/[id].ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "@lib/helpers/withMiddleware"; -import type { MembershipResponse } from "@lib/types"; -import { schemaMembershipBodyParams, schemaMembershipPublic } from "@lib/validations/membership"; -import { schemaQueryIdAsString, withValidQueryIdString } from "@lib/validations/shared/queryIdString"; - -export async function membershipById( - { method, query, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdAsString.safeParse(query); - if (!safeQuery.success) { - res.status(400).json({ message: "Your query was invalid" }); - return; - } - // This is how we set the userId and teamId in the query for managing compoundId. - const [paramUserId, teamId] = safeQuery.data.id.split("_"); - if (parseInt(paramUserId) !== userId) res.status(401).json({ message: "Unauthorized" }); - else { - switch (method) { - /** - * @swagger - * /memberships/{userId}_{teamId}: - * get: - * summary: Find a membership by userID and teamID - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: Membership was not found - */ - case "GET": - await prisma.membership - .findUnique({ - where: { - userId_teamId: { - userId: userId, - teamId: parseInt(teamId), - }, - }, - }) - .then((data) => schemaMembershipPublic.parse(data)) - .then((membership) => res.status(200).json({ membership })) - .catch((error: Error) => - res.status(404).json({ - message: `Membership with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - - /** - * @swagger - * /memberships/{userId}_{teamId}: - * patch: - * summary: Edit an existing membership - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership edited successfuly - * 400: - * description: Bad request. Membership body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "PATCH": - const safeBody = schemaMembershipBodyParams.safeParse(body); - if (!safeBody.success) { - { - res.status(400).json({ message: "Invalid request body" }); - return; - } - } - await prisma.membership - .update({ - where: { - userId_teamId: { - userId: userId, - teamId: parseInt(teamId), - }, - }, - data: safeBody.data, - }) - .then((data) => schemaMembershipPublic.parse(data)) - .then((membership) => res.status(200).json({ membership })) - .catch((error: Error) => - res.status(404).json({ - message: `Membership with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - - /** - * @swagger - * /memberships/{userId}_{teamId}: - * delete: - * summary: Remove an existing membership - * parameters: - * - in: path - * name: userId - * schema: - * type: integer - * required: true - * description: Numeric userId of the membership to get - * - in: path - * name: teamId - * schema: - * type: integer - * required: true - * description: Numeric teamId of the membership to get - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership removed successfuly - * 400: - * description: Bad request. Membership id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - case "DELETE": - await prisma.membership - .delete({ - where: { - userId_teamId: { - userId: userId, - teamId: parseInt(teamId), - }, - }, - }) - .then(() => - res.status(200).json({ - message: `Membership with id: ${safeQuery.data.id} deleted successfully`, - }) - ) - .catch((error: Error) => - res.status(404).json({ - message: `Membership with id: ${safeQuery.data.id} not found`, - error, - }) - ); - break; - - default: - res.status(405).json({ message: "Method not allowed" }); - break; - } - } -} - -export default withMiddleware("HTTP_GET_DELETE_PATCH")(withValidQueryIdString(membershipById)); diff --git a/pages/api/memberships/[id]/_auth-middleware.ts b/pages/api/memberships/[id]/_auth-middleware.ts new file mode 100644 index 0000000000..5da3083b4e --- /dev/null +++ b/pages/api/memberships/[id]/_auth-middleware.ts @@ -0,0 +1,17 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { membershipIdSchema } from "@lib/validations/membership"; + +async function authMiddleware(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const { teamId } = membershipIdSchema.parse(req.query); + // Admins can just skip this check + if (isAdmin) return; + // Only team members can modify a membership + const membership = await prisma.membership.findFirst({ where: { userId, teamId } }); + if (!membership) throw new HttpError({ statusCode: 403, message: "Forbidden" }); +} + +export default authMiddleware; diff --git a/pages/api/memberships/[id]/_delete.ts b/pages/api/memberships/[id]/_delete.ts new file mode 100644 index 0000000000..a21faea80f --- /dev/null +++ b/pages/api/memberships/[id]/_delete.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { membershipIdSchema } from "@lib/validations/membership"; +import { schemaQueryIdAsString } from "@lib/validations/shared/queryIdString"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * delete: + * summary: Remove an existing membership + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership removed successfuly + * 400: + * description: Bad request. Membership id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const { prisma, query } = req; + const userId_teamId = membershipIdSchema.parse(query); + await prisma.membership.delete({ where: { userId_teamId } }); + return { message: `Membership with id: ${query.id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/pages/api/memberships/[id]/_get.ts b/pages/api/memberships/[id]/_get.ts new file mode 100644 index 0000000000..626395c878 --- /dev/null +++ b/pages/api/memberships/[id]/_get.ts @@ -0,0 +1,46 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import { membershipIdSchema, schemaMembershipPublic } from "@lib/validations/membership"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * get: + * summary: Find a membership by userID and teamID + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: Membership was not found + */ +export async function getHandler(req: NextApiRequest) { + const { prisma, query } = req; + const userId_teamId = membershipIdSchema.parse(query); + const args: Prisma.MembershipFindUniqueOrThrowArgs = { where: { userId_teamId } }; + // Just in case the user want to get more info about the team itself + if (req.query.include === "team") args.include = { team: true }; + const data = await prisma.membership.findUniqueOrThrow(args); + return { membership: schemaMembershipPublic.parse(data) }; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/memberships/[id]/_patch.ts b/pages/api/memberships/[id]/_patch.ts new file mode 100644 index 0000000000..a458e1782a --- /dev/null +++ b/pages/api/memberships/[id]/_patch.ts @@ -0,0 +1,71 @@ +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 { + membershipEditBodySchema, + membershipIdSchema, + schemaMembershipPublic, +} from "@lib/validations/membership"; + +/** + * @swagger + * /memberships/{userId}_{teamId}: + * patch: + * summary: Edit an existing membership + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Numeric userId of the membership to get + * - in: path + * name: teamId + * schema: + * type: integer + * required: true + * description: Numeric teamId of the membership to get + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership edited successfully + * 400: + * description: Bad request. Membership body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const { prisma, query } = req; + const userId_teamId = membershipIdSchema.parse(query); + const data = membershipEditBodySchema.parse(req.body); + const args: Prisma.MembershipUpdateArgs = { where: { userId_teamId }, data }; + + await checkPermissions(req); + + const result = await prisma.membership.update(args); + return { membership: schemaMembershipPublic.parse(result) }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + const { userId: queryUserId, teamId } = membershipIdSchema.parse(req.query); + const data = membershipEditBodySchema.parse(req.body); + // Admins can just skip this check + if (isAdmin) return; + // Only the invited user can accept the invite + if ("accepted" in data && queryUserId !== userId) + throw new HttpError({ statusCode: 403, message: "Only the invited user can accept the invite" }); + // Only team OWNERS and ADMINS can modify `role` + if ("role" in data) { + const membership = await prisma.membership.findFirst({ + where: { userId, teamId, role: { in: ["ADMIN", "OWNER"] } }, + }); + if (!membership) throw new HttpError({ statusCode: 403, message: "Forbidden" }); + } +} + +export default defaultResponder(patchHandler); diff --git a/pages/api/memberships/[id]/index.ts b/pages/api/memberships/[id]/index.ts new file mode 100644 index 0000000000..e0839f1eff --- /dev/null +++ b/pages/api/memberships/[id]/index.ts @@ -0,0 +1,18 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; + +import authMiddleware from "./_auth-middleware"; + +export default withMiddleware()( + defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => { + await authMiddleware(req); + return defaultHandler({ + GET: import("./_get"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + })(req, res); + }) +); diff --git a/pages/api/memberships/_get.ts b/pages/api/memberships/_get.ts new file mode 100644 index 0000000000..52c29f50e8 --- /dev/null +++ b/pages/api/memberships/_get.ts @@ -0,0 +1,76 @@ +import { Prisma } from "@prisma/client"; +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import { schemaMembershipPublic } from "@lib/validations/membership"; +import { + schemaQuerySingleOrMultipleTeamIds, + schemaQuerySingleOrMultipleUserIds, +} from "@lib/validations/shared/queryUserId"; + +/** + * @swagger + * /memberships: + * get: + * summary: Find all memberships + * tags: + * - memberships + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No memberships were found + */ +async function getHandler(req: NextApiRequest) { + const { prisma } = req; + const args: Prisma.MembershipFindManyArgs = { + where: { + /** Admins can query multiple users */ + userId: { in: getUserIds(req) }, + /** Admins can query multiple teams as well */ + teamId: { in: getTeamIds(req) }, + }, + }; + // Just in case the user want to get more info about the team itself + if (req.query.include === "team") args.include = { team: true }; + + const data = await prisma.membership.findMany(args); + return { memberships: data.map((v) => schemaMembershipPublic.parse(v)) }; +} + +/** + * Returns requested users IDs only if admin, otherwise return only current user ID + */ +function getUserIds(req: NextApiRequest) { + const { userId, isAdmin } = req; + /** Only admins can query other users */ + if (!isAdmin && req.query.userId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isAdmin && req.query.userId) { + const query = schemaQuerySingleOrMultipleUserIds.parse(req.query); + const userIds = Array.isArray(query.userId) ? query.userId : [query.userId || userId]; + return userIds; + } + // Return all memberships for ADMIN, limit to current user to non-admins + return isAdmin ? undefined : [userId]; +} + +/** + * Returns requested teams IDs only if admin + */ +function getTeamIds(req: NextApiRequest) { + const { isAdmin } = req; + /** Only admins can query other teams */ + if (!isAdmin && req.query.teamId) throw new HttpError({ statusCode: 403, message: "ADMIN required" }); + if (isAdmin && req.query.teamId) { + const query = schemaQuerySingleOrMultipleTeamIds.parse(req.query); + const teamIds = Array.isArray(query.teamId) ? query.teamId : [query.teamId]; + return teamIds; + } + return undefined; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/memberships/_post.ts b/pages/api/memberships/_post.ts new file mode 100644 index 0000000000..d76e4abf83 --- /dev/null +++ b/pages/api/memberships/_post.ts @@ -0,0 +1,53 @@ +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 { membershipCreateBodySchema, schemaMembershipPublic } from "@lib/validations/membership"; + +/** + * @swagger + * /memberships: + * post: + * summary: Creates a new membership + * tags: + * - memberships + * responses: + * 201: + * description: OK, membership created + * 400: + * description: Bad request. Membership body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +async function postHandler(req: NextApiRequest) { + const { prisma } = req; + const data = membershipCreateBodySchema.parse(req.body); + const args: Prisma.MembershipCreateArgs = { data }; + + await checkPermissions(req); + + const result = await prisma.membership.create(args); + + return { + membership: schemaMembershipPublic.parse(result), + message: "Membership created successfully", + }; +} + +async function checkPermissions(req: NextApiRequest) { + const { userId, isAdmin, prisma } = req; + if (isAdmin) return; + const body = membershipCreateBodySchema.parse(req.body); + // To prevent auto-accepted invites, limit it to ADMIN users + if (!isAdmin && "accepted" in body) + throw new HttpError({ statusCode: 403, message: "ADMIN needed for `accepted`" }); + // Only team OWNERS and ADMINS can add other members + const membership = await prisma.membership.findFirst({ + where: { userId, teamId: body.teamId, role: { in: ["ADMIN", "OWNER"] } }, + }); + if (!membership) throw new HttpError({ statusCode: 403, message: "You can't add members to this team" }); +} + +export default defaultResponder(postHandler); diff --git a/pages/api/memberships/index.ts b/pages/api/memberships/index.ts index 5aaa36f6db..4c33cbe75d 100644 --- a/pages/api/memberships/index.ts +++ b/pages/api/memberships/index.ts @@ -1,71 +1,10 @@ -import type { NextApiRequest, NextApiResponse } from "next"; +import { defaultHandler } from "@calcom/lib/server"; import { withMiddleware } from "@lib/helpers/withMiddleware"; -import { MembershipResponse, MembershipsResponse } from "@lib/types"; -import { schemaMembershipBodyParams, schemaMembershipPublic } from "@lib/validations/membership"; -async function createOrlistAllMemberships( - { method, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - if (method === "GET") { - /** - * @swagger - * /memberships: - * get: - * summary: Find all memberships - * tags: - * - memberships - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: No memberships were found - */ - const data = await prisma.membership.findMany({ where: { userId } }); - const memberships = data.map((membership) => schemaMembershipPublic.parse(membership)); - if (memberships) res.status(200).json({ memberships }); - else - (error: Error) => - res.status(404).json({ - message: "No Memberships were found", - error, - }); - } else if (method === "POST") { - /** - * @swagger - * /memberships: - * post: - * summary: Creates a new membership - * tags: - * - memberships - * responses: - * 201: - * description: OK, membership created - * 400: - * description: Bad request. Membership body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - const safe = schemaMembershipBodyParams.safeParse(body); - if (!safe.success) { - res.status(400).json({ message: "Invalid request body" }); - return; - } - - const data = await prisma.membership.create({ data: { ...safe.data, userId } }); - const membership = schemaMembershipPublic.parse(data); - - if (membership) res.status(201).json({ membership, message: "Membership created successfully" }); - else - (error: Error) => - res.status(400).json({ - message: "Could not create new membership", - error, - }); - } else res.status(405).json({ message: `Method ${method} not allowed` }); -} - -export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllMemberships); +export default withMiddleware()( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +);