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:
parent
bae3bd76e5
commit
877cd4cdff
|
@ -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) }),
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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, {
|
||||
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, {
|
||||
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, {
|
||||
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, {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
Loading…
Reference in New Issue
Block a user