added more endpoints and validations for publish-pay teams (#209)

## What does the PR do?

- Team billing via API

Just like the web project, we validate that team has stripe metadata
before converting requestedSlug to slug.

Co-authored-by: zomars <zomars@me.com>
pull/9078/head
alannnc 2022-11-22 14:24:25 -06:00 committed by GitHub
parent 8e25b9244c
commit c016a4343d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 142 additions and 17 deletions

View File

@ -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({});

View File

@ -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";

View File

@ -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";

View File

@ -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;

View File

@ -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);

View File

@ -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<typeof data.metadata> | 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);

View File

@ -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);
})
);

View File

@ -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<typeof data.metadata> | 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 },