refactor: Team Creation Flow [CAL-2751] (#12501)

* Create new endpoint for creating a team

* Generate a team checkout session

* Create team navigate to checkout

* Clean up

* UI changes

* Add comments

* Fix

* Type fix

* Type fix

* Type fix

* Type fixes

* Set telemetry

* Import fix

* Type fix

* Update tests

* Type fix

* fix: e2e

* fix: e2e

* fix: e2e

* fix: e2e

* Update teams.e2e.ts

* fix: e2e

---------

Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Joe Au-Yeung 2023-11-29 11:39:21 -05:00 committed by GitHub
parent bae3bd76e5
commit 877cd4cdff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 353 additions and 169 deletions

View File

@ -0,0 +1,92 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
const querySchema = z.object({
session_id: z.string().min(1),
});
const checkoutSessionMetadataSchema = z.object({
teamName: z.string(),
teamSlug: z.string(),
userId: z.string().transform(Number),
});
const generateRandomString = () => {
return Math.random().toString(36).substring(2, 10);
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { session_id } = querySchema.parse(req.query);
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["subscription"],
});
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
const subscription = checkoutSession.subscription as Stripe.Subscription;
if (checkoutSession.payment_status !== "paid")
throw new HttpError({ statusCode: 402, message: "Payment required" });
// Let's query to ensure that the team metadata carried over from the checkout session.
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
if (!parseCheckoutSessionMetadata.success) {
console.error(
"Team metadata not found in checkout session",
parseCheckoutSessionMetadata.error,
checkoutSession.id
);
}
if (!checkoutSession.metadata?.userId) {
throw new HttpError({
statusCode: 400,
message: "Can't publish team/org without userId",
});
}
const checkoutSessionMetadata = parseCheckoutSessionMetadata.success
? parseCheckoutSessionMetadata.data
: {
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
userId: checkoutSession.metadata.userId,
};
const team = await prisma.team.create({
data: {
name: checkoutSessionMetadata.teamName,
slug: checkoutSessionMetadata.teamSlug,
members: {
create: {
userId: checkoutSessionMetadata.userId as number,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
paymentId: checkoutSession.id,
subscriptionId: subscription.id || null,
subscriptionItemId: subscription.items.data[0].id || null,
},
},
});
// Sync Services: Close.com
// closeComUpdateTeam(prevTeam, team);
// redirect to team screen
res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@ -24,7 +24,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
// there should be one, otherwise we throw
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
email: `${user.username}@example.com`,
email: user.email,
},
select: {
id: true,
@ -37,7 +37,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
// Test when a user changes his email after starting the password reset flow
await prisma.user.update({
where: {
email: `${user.username}@example.com`,
email: user.email,
},
data: {
email: `${user.username}-2@example.com`,
@ -54,7 +54,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
email: `${user.username}-2@example.com`,
},
data: {
email: `${user.username}@example.com`,
email: user.email,
},
});
@ -75,7 +75,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
// we're not logging in to the UI to speed up test performance.
const updatedUser = await prisma.user.findUniqueOrThrow({
where: {
email: `${user.username}@example.com`,
email: user.email,
},
select: {
id: true,
@ -84,10 +84,10 @@ test("Can reset forgotten password", async ({ page, users }) => {
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
await page.goto(`/auth/forgot-password/${id}`);
await page.waitForSelector("text=That request is expired.");
await expect(page.locator(`text=Whoops`)).toBeVisible();
});

View File

@ -295,3 +295,11 @@ export function generateTotpCode(email: string) {
totp.options = { step: 90 };
return totp.generate(secret);
}
export async function fillStripeTestCheckout(page: Page) {
await page.fill("[name=cardNumber]", "4242424242424242");
await page.fill("[name=cardExpiry]", "12/30");
await page.fill("[name=cardCvc]", "111");
await page.fill("[name=billingName]", "Stripe Stripeson");
await page.click(".SubmitButton--complete-Shimmer");
}

View File

@ -1,9 +1,15 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
import { localize } from "./lib/testUtils";
import {
bookTimeSlot,
fillStripeTestCheckout,
localize,
selectFirstAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
@ -21,18 +27,20 @@ test.describe("Managed Event Types tests", () => {
await test.step("Managed event option exists for team admin", async () => {
// Filling team creation form wizard
await page.locator('input[name="name"]').waitFor();
await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`);
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`);
await page.getByTestId("invite-new-member-button").click();
// wait for the second member to be added to the pending-member-list.
await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor();
// and publish
await page.locator("text=Publish team").click();
await page.waitForURL("/settings/teams/**");
await page.locator("[data-testid=publish-button]").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
// Going to create an event type
await page.goto("/event-types");
await page.getByTestId("new-event-type").click();

View File

@ -1,34 +1,32 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
import {
bookTimeSlot,
fillStripeTestCheckout,
selectFirstAvailableTimeSlotNextMonth,
testName,
todo,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.describe("Teams - NonOrg", () => {
test.afterEach(({ users }) => users.deleteAll());
test("Can create teams via Wizard", async ({ page, users }) => {
const user = await users.create();
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
await page.goto("/teams");
await test.step("Can create team", async () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
await page.waitForURL("/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
});
test("Team Onboarding Invite Members", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
const { team } = await user.getFirstTeam();
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
page.goto(`/settings/teams/${team.id}/onboard-members`);
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
@ -50,9 +48,9 @@ test.describe("Teams - NonOrg", () => {
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can publish team", async () => {
await page.locator("text=Publish team").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await test.step("Finishing brings you to team profile page", async () => {
await page.locator("[data-testid=publish-button]").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can disband team", async () => {
@ -66,7 +64,6 @@ test.describe("Teams - NonOrg", () => {
});
test("Can create a booking for Collective EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
@ -74,11 +71,14 @@ test.describe("Teams - NonOrg", () => {
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
@ -102,18 +102,20 @@ test.describe("Teams - NonOrg", () => {
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
@ -134,7 +136,7 @@ test.describe("Teams - NonOrg", () => {
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
@ -164,8 +166,7 @@ test.describe("Teams - NonOrg", () => {
await page.goto("/settings/teams/new");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
await page.locator("text=Continue").click();
await expect(page.locator("[data-testid=alert]")).toBeVisible();
await page.click("[type=submit]");
// cleanup
const org = await owner.getOrgMembership();
@ -174,11 +175,9 @@ test.describe("Teams - NonOrg", () => {
});
test("Can create team with same name as user", async ({ page, users }) => {
const user = await users.create();
// Name to be used for both user and team
const uniqueName = "test-unique-name";
const ownerObj = { username: uniqueName, name: uniqueName, useExactUsername: true };
const user = await users.create(ownerObj);
const uniqueName = user.username!;
await user.apiLogin();
await page.goto("/teams");
@ -189,11 +188,14 @@ test.describe("Teams - NonOrg", () => {
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
// Click text=Continue
await page.locator("text=Publish team").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.locator("[data-testid=publish-button]").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can access user and team with same slug", async () => {
@ -210,13 +212,11 @@ test.describe("Teams - NonOrg", () => {
await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName);
// cleanup team
const team = await prisma.team.findFirst({ where: { slug: uniqueName } });
await prisma.team.delete({ where: { id: team?.id } });
await prisma.team.deleteMany({ where: { slug: uniqueName } });
});
});
test("Can create a private team", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
@ -224,11 +224,14 @@ test.describe("Teams - NonOrg", () => {
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
});
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.COLLECTIVE,
}
);
await owner.apiLogin();
const { team } = await owner.getFirstTeam();
@ -278,45 +281,43 @@ test.describe("Teams - Org", () => {
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
});
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
await page.locator('[data-testid="new-member-button"]').click();
// Fill [placeholder="email\@example\.com"]
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
// Click [data-testid="invite-new-member-button"]
await page.locator('[data-testid="invite-new-member-button"]').click();
await page.getByTestId("invite-new-member-button").click();
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
});
await test.step("Can remove members", async () => {
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
await lastRemoveMemberButton.click();
await page.waitForLoadState("networkidle");
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team creation", async () => {
await page.locator("text=Finish").click();
await page.waitForURL("/settings/teams");
await page.getByTestId("publish-button").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
await test.step("Can disband team", async () => {
await page.locator('[data-testid="team-list-item-link"]').click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
await page.locator("text=Disband Team").click();
await page.locator("text=Yes, disband team").click();
await page.getByTestId("disband-team-button").click();
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
});
@ -361,13 +362,13 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// The booker should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
// All the teammates should be in the booking
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
@ -380,18 +381,20 @@ test.describe("Teams - Org", () => {
});
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
const ownerObj = { username: "pro-user", name: "pro-user" };
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(ownerObj, {
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
});
const owner = await users.create(
{ username: "pro-user", name: "pro-user" },
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeam();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
@ -402,17 +405,17 @@ test.describe("Teams - Org", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// The person who booked the meeting should be in the attendee list
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
// The title of the booking
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
// Since all the users have the same leastRecentlyBooked value
// Anyone of the teammates could be the Host of the booking.
const chosenUser = await page.getByTestId("booking-host-name").textContent();
expect(chosenUser).not.toBeNull();
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
});

View File

@ -1,6 +1,6 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
@ -10,6 +10,7 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
import { MembershipRole } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@ -33,11 +34,21 @@ type FormValues = {
const AddNewTeamMembers = () => {
const searchParams = useCompatSearchParams();
const session = useSession();
const telemetry = useTelemetry();
const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1;
const teamQuery = trpc.viewer.teams.get.useQuery(
{ teamId },
{ enabled: session.status === "authenticated" }
);
useEffect(() => {
const event = searchParams?.get("event");
if (event === "team_created") {
telemetry.event(telemetryEventTypes.team_created);
}
}, []);
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({
)}
<hr className="border-subtle my-6" />
<Button
data-testid="publish-button"
EndIcon={!orgBranding ? ArrowRight : undefined}
color="primary"
className="w-full justify-center"
disabled={publishTeamMutation.isLoading}
onClick={() => {
if (orgBranding) {
router.push("/settings/teams");
} else {
publishTeamMutation.mutate({ teamId });
}
router.push(`/settings/teams/${teamId}/profile`);
}}>
{t(orgBranding ? "finish" : "team_publish")}
{t("finish")}
</Button>
</>
);

View File

@ -4,14 +4,15 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import slugify from "@calcom/lib/slugify";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Form, ImageUploader, TextField, Alert, Label } from "@calcom/ui";
import { ArrowRight, Plus } from "@calcom/ui/components/icon";
import { Alert, Button, Form, TextField } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import type { NewTeamFormValues } from "../lib/types";
@ -21,8 +22,19 @@ const querySchema = z.object({
slug: z.string().optional(),
});
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
submitLabel: "checkout",
}
: {
telemetryEvent: telemetryEventTypes.team_created,
submitLabel: "continue",
};
export const CreateANewTeamForm = () => {
const { t } = useLocale();
const { t, isLocaleReady } = useLocale();
const router = useRouter();
const telemetry = useTelemetry();
const params = useParamsWithFallback();
@ -42,8 +54,8 @@ export const CreateANewTeamForm = () => {
const createTeamMutation = trpc.viewer.teams.create.useMutation({
onSuccess: (data) => {
telemetry.event(telemetryEventTypes.team_created);
router.push(`/settings/teams/${data.id}/onboard-members`);
telemetry.event(flag.telemetryEvent);
router.push(data.url);
},
onError: (err) => {
if (err.message === "team_url_taken") {
@ -81,6 +93,10 @@ export const CreateANewTeamForm = () => {
render={({ field: { value } }) => (
<>
<TextField
disabled={
/* E2e is too fast and it tries to fill this way before the form is ready */
!isLocaleReady || createTeamMutation.isLoading
}
className="mt-2"
placeholder="Acme Inc."
name="name"
@ -128,38 +144,6 @@ export const CreateANewTeamForm = () => {
/>
</div>
<div className="mb-8">
<Controller
control={newTeamFormMethods.control}
name="logo"
render={({ field: { value } }) => (
<>
<Label>{t("team_logo")}</Label>
<div className="flex items-center">
<Avatar
alt=""
imageSrc={value}
fallback={<Plus className="text-subtle h-6 w-6" />}
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newAvatar: string) => {
newTeamFormMethods.setValue("logo", newAvatar);
createTeamMutation.reset();
}}
imageSrc={value}
/>
</div>
</div>
</>
)}
/>
</div>
<div className="flex space-x-2 rtl:space-x-reverse">
<Button
disabled={createTeamMutation.isLoading}
@ -174,7 +158,7 @@ export const CreateANewTeamForm = () => {
EndIcon={ArrowRight}
type="submit"
className="w-full justify-center">
{t("continue")}
{t(flag.submitLabel)}
</Button>
</div>
</Form>

View File

@ -29,6 +29,51 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
};
/**
* Used to generate a checkout session when trying to create a team
*/
export const generateTeamCheckoutSession = async ({
teamName,
teamSlug,
userId,
}: {
teamName: string;
teamSlug: string;
userId: number;
}) => {
const customer = await getStripeCustomerIdFromUserId(userId);
const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
allow_promotion_codes: true,
success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
line_items: [
{
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
/**Initially it will be just the team owner */
quantity: 1,
},
],
customer_update: {
address: "auto",
},
automatic_tax: {
enabled: true,
},
metadata: {
teamName,
teamSlug,
userId,
},
});
return session;
};
/**
* Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams
*/
export const purchaseTeamSubscription = async (input: {
teamId: number;
seats: number;

View File

@ -194,7 +194,11 @@ const ProfileView = () => {
<Dialog>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
<Button
color="destructive"
className="border"
StartIcon={Trash2}
data-testid="disband-team-button">
{t("disband_team")}
</Button>
</DialogTrigger>

View File

@ -88,7 +88,7 @@ export const IS_STRIPE_ENABLED = !!(
process.env.STRIPE_PRIVATE_KEY
);
/** Self hosted shouldn't checkout when creating teams unless required */
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && (!IS_SELF_HOSTED || HOSTED_CAL_FEATURES);
export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES;
export const FULL_NAME_LENGTH_MAX_LIMIT = 50;
export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5";

View File

@ -18,6 +18,7 @@ export const telemetryEventTypes = {
onboardingFinished: "onboarding_finished",
onboardingStarted: "onboarding_started",
signup: "signup",
team_checkout_session_created: "team_checkout_session_created",
team_created: "team_created",
slugReplacementAction: "slug_replacement_action",
org_created: "org_created",

View File

@ -1,3 +1,5 @@
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@ -14,6 +16,34 @@ type CreateOptions = {
input: TCreateInputSchema;
};
const generateCheckoutSession = async ({
teamSlug,
teamName,
userId,
}: {
teamSlug: string;
teamName: string;
userId: number;
}) => {
if (!IS_TEAM_BILLING_ENABLED) {
console.info("Team billing is disabled, not generating a checkout session.");
return;
}
const checkoutSession = await generateTeamCheckoutSession({
teamSlug,
teamName,
userId,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish team" };
};
export const createHandler = async ({ ctx, input }: CreateOptions) => {
const { user } = ctx;
const { slug, name, logo } = input;
@ -45,28 +75,26 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" });
}
// Ensure that the user is not duplicating a requested team
const duplicatedRequest = await prisma.team.findFirst({
where: {
members: {
some: {
userId: ctx.user.id,
},
},
metadata: {
path: ["requestedSlug"],
equals: slug,
},
},
});
// If the user is not a part of an org, then make them pay before creating the team
if (!isOrgChildTeam) {
const checkoutSession = await generateCheckoutSession({
teamSlug: slug,
teamName: name,
userId: user.id,
});
if (duplicatedRequest) {
return duplicatedRequest;
// If there is a checkout session, return it. Otherwise, it means it's disabled.
if (checkoutSession)
return {
url: checkoutSession.url,
message: checkoutSession.message,
team: null,
};
}
const createTeam = await prisma.team.create({
const createdTeam = await prisma.team.create({
data: {
...(isOrgChildTeam ? { slug } : {}),
slug,
name,
logo,
members: {
@ -76,17 +104,16 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
accepted: true,
},
},
metadata: !isOrgChildTeam
? {
requestedSlug: slug,
}
: undefined,
...(isOrgChildTeam && { parentId: user.organizationId }),
},
});
// Sync Services: Close.com
closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER);
closeComUpsertTeamUser(createdTeam, ctx.user, MembershipRole.OWNER);
return createTeam;
return {
url: `${WEBAPP_URL}/settings/teams/${createdTeam.id}/onboard-members`,
message: "Team billing is disabled, not generating a checkout session.",
team: createdTeam,
};
};

View File

@ -69,7 +69,11 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
{confirmBtn ? (
confirmBtn
) : (
<DialogClose color="primary" loading={isLoading} onClick={(e) => onConfirm && onConfirm(e)}>
<DialogClose
color="primary"
loading={isLoading}
onClick={(e) => onConfirm && onConfirm(e)}
data-testid="dialog-confirmation">
{isLoading ? loadingText : confirmBtnText}
</DialogClose>
)}