From 877cd4cdff7db466ebfd82e42775dc972e2d2004 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 29 Nov 2023 11:39:21 -0500 Subject: [PATCH 1/8] refactor: Team Creation Flow [CAL-2751] (#12501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/web/pages/api/teams/create.ts | 92 ++++++++++ .../playwright/auth/forgot-password.e2e.ts | 12 +- apps/web/playwright/lib/testUtils.ts | 8 + .../web/playwright/managed-event-types.e2e.ts | 24 ++- apps/web/playwright/teams.e2e.ts | 167 +++++++++--------- .../ee/teams/components/AddNewTeamMembers.tsx | 22 ++- .../teams/components/CreateANewTeamForm.tsx | 60 +++---- packages/features/ee/teams/lib/payments.ts | 45 +++++ .../ee/teams/pages/team-profile-view.tsx | 6 +- packages/lib/constants.ts | 2 +- packages/lib/telemetry.ts | 1 + .../routers/viewer/teams/create.handler.ts | 77 +++++--- .../dialog/ConfirmationDialogContent.tsx | 6 +- 13 files changed, 353 insertions(+), 169 deletions(-) create mode 100644 apps/web/pages/api/teams/create.ts diff --git a/apps/web/pages/api/teams/create.ts b/apps/web/pages/api/teams/create.ts new file mode 100644 index 0000000000..b0c8935839 --- /dev/null +++ b/apps/web/pages/api/teams/create.ts @@ -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) }), +}); diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index 0524a538e0..ad4d30aa3a 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -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(); }); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 1deefb206f..5bb711d71a 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -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"); +} diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index a0323ed8b7..3db81e362c 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -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(); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index ba9f2960d6..4c8cae0bd5 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -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 }); }); diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index 750f8ecbae..da444655da 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -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 ; return ; @@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({ )}
); diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx index 76b2cbc6db..14de3a1588 100644 --- a/packages/features/ee/teams/components/CreateANewTeamForm.tsx +++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx @@ -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 } }) => ( <> { /> -
- ( - <> - -
- } - size="lg" - /> -
- { - newTeamFormMethods.setValue("logo", newAvatar); - createTeamMutation.reset(); - }} - imageSrc={value} - /> -
-
- - )} - /> -
-
diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 5a0b50f31b..5e44add974 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -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; diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 294ce36fbd..88c53e991f 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -194,7 +194,11 @@ const ProfileView = () => { - diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index f9fb122f34..6f1a66ab9a 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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"; diff --git a/packages/lib/telemetry.ts b/packages/lib/telemetry.ts index 5e4636eaf8..757ab05161 100644 --- a/packages/lib/telemetry.ts +++ b/packages/lib/telemetry.ts @@ -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", diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 2b861c7d88..4c3ffffd18 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -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, + }; }; diff --git a/packages/ui/components/dialog/ConfirmationDialogContent.tsx b/packages/ui/components/dialog/ConfirmationDialogContent.tsx index b25fbbc9c9..c615a81acc 100644 --- a/packages/ui/components/dialog/ConfirmationDialogContent.tsx +++ b/packages/ui/components/dialog/ConfirmationDialogContent.tsx @@ -69,7 +69,11 @@ export function ConfirmationDialogContent(props: PropsWithChildren onConfirm && onConfirm(e)}> + onConfirm && onConfirm(e)} + data-testid="dialog-confirmation"> {isLoading ? loadingText : confirmBtnText} )} From ea8437b4f7855e2e644b560ae4b9a104362222da Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 29 Nov 2023 19:57:51 +0200 Subject: [PATCH 2/8] fix: invite member by username (#12591) * fix: invite member by username * fixup! fix: invite member by username --- .../inviteMember/inviteMember.handler.ts | 37 +++++++++++------ .../teams/inviteMember/inviteMember.schema.ts | 41 +++++++++---------- .../inviteMember/inviteMemberUtils.test.ts | 24 ++++++++--- .../viewer/teams/inviteMember/utils.ts | 20 +++++++-- 4 files changed, 78 insertions(+), 44 deletions(-) diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 41b081433b..deb8a55019 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -9,7 +9,7 @@ import type { TInviteMemberInputSchema } from "./inviteMember.schema"; import { checkPermissions, getTeamOrThrow, - getEmailsToInvite, + getUsernameOrEmailsToInvite, getOrgConnectionInfo, getIsOrgVerified, sendVerificationEmail, @@ -42,43 +42,54 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = const team = await getTeamOrThrow(input.teamId, input.isOrg); const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team); - const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail); - const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => { + const usernameOrEmailsToInvite = await getUsernameOrEmailsToInvite(input.usernameOrEmail); + const orgConnectInfoByUsernameOrEmail = usernameOrEmailsToInvite.reduce((acc, usernameOrEmail) => { return { ...acc, - [email]: getOrgConnectionInfo({ + [usernameOrEmail]: getOrgConnectionInfo({ orgVerified, orgAutoAcceptDomain: autoAcceptEmailDomain, - usersEmail: email, + usersEmail: usernameOrEmail, team, isOrg: input.isOrg, }), }; }, {} as Record>); const existingUsersWithMembersips = await getUsersToInvite({ - usernameOrEmail: emailsToInvite, + usernamesOrEmails: usernameOrEmailsToInvite, isInvitedToOrg: input.isOrg, team, }); - const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email); - const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email)); + const existingUsersEmailsAndUsernames = existingUsersWithMembersips.reduce( + (acc, user) => ({ + emails: user.email ? [...acc.emails, user.email] : acc.emails, + usernames: user.username ? [...acc.usernames, user.username] : acc.usernames, + }), + { emails: [], usernames: [] } as { emails: string[]; usernames: string[] } + ); + const newUsersEmailsOrUsernames = usernameOrEmailsToInvite.filter( + (usernameOrEmail) => + !existingUsersEmailsAndUsernames.emails.includes(usernameOrEmail) && + !existingUsersEmailsAndUsernames.usernames.includes(usernameOrEmail) + ); + // deal with users to create and invite to team/org - if (newUsersEmails.length) { + if (newUsersEmailsOrUsernames.length) { await createNewUsersConnectToOrgIfExists({ - usernamesOrEmails: newUsersEmails, + usernamesOrEmails: newUsersEmailsOrUsernames, input, - connectionInfoMap: orgConnectInfoByEmail, + connectionInfoMap: orgConnectInfoByUsernameOrEmail, autoAcceptEmailDomain, parentId: team.parentId, }); - const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => { + const sendVerifEmailsPromises = newUsersEmailsOrUsernames.map((usernameOrEmail) => { return sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, - connectionInfo: orgConnectInfoByEmail[usernameOrEmail], + connectionInfo: orgConnectInfoByUsernameOrEmail[usernameOrEmail], }); }); sendEmails(sendVerifEmailsPromises); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts index 35c59a3523..19f354a59e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -2,8 +2,6 @@ import { z } from "zod"; import { MembershipRole } from "@calcom/prisma/enums"; -import { TRPCError } from "@trpc/server"; - export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), usernameOrEmail: z @@ -14,27 +12,26 @@ export const ZInviteMemberInputSchema = z.object({ } return usernameOrEmail.map((item) => item.trim().toLowerCase()); }) - .refine((value) => { - let invalidEmail; - if (Array.isArray(value)) { - if (value.length > 100) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `You are limited to inviting a maximum of 100 users at once.`, - }); + .refine( + (value) => { + if (Array.isArray(value)) { + if (value.length > 100) { + return false; + } } - invalidEmail = value.find((email) => !z.string().email().safeParse(email).success); - } else { - invalidEmail = !z.string().email().safeParse(value).success ? value : null; - } - if (invalidEmail) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invite failed because '${invalidEmail}' is not a valid email address`, - }); - } - return true; - }), + return true; + }, + { message: "You are limited to inviting a maximum of 100 users at once." } + ) + .refine( + (value) => { + if (Array.isArray(value)) { + return !value.some((email) => !z.string().email().safeParse(email).success); + } + return true; + }, + { message: "Bulk invitations are restricted to email addresses only." } + ), role: z.nativeEnum(MembershipRole), language: z.string(), isOrg: z.boolean().default(false), diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index 6dc4ea15df..d7207b8505 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -10,11 +10,12 @@ import type { TeamWithParent } from "./types"; import type { Invitee, UserWithMembership } from "./utils"; import { checkPermissions, - getEmailsToInvite, + getUsernameOrEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, validateInviteeEligibility, shouldAutoJoinIfInOrg, + checkInputEmailIsValid, } from "./utils"; vi.mock("@calcom/lib/server/queries", () => { @@ -101,22 +102,35 @@ describe("Invite Member Utils", () => { await expect(checkPermissions({ userId: 1, teamId: 1 })).resolves.not.toThrow(); }); }); - describe("getEmailsToInvite", () => { + describe("getUsernameOrEmailsToInvite", () => { it("should throw a TRPCError with code BAD_REQUEST if no emails are provided", async () => { - await expect(getEmailsToInvite([])).rejects.toThrow(TRPCError); + await expect(getUsernameOrEmailsToInvite([])).rejects.toThrow(TRPCError); }); it("should return an array with one email if a string is provided", async () => { - const result = await getEmailsToInvite("test@example.com"); + const result = await getUsernameOrEmailsToInvite("test@example.com"); expect(result).toEqual(["test@example.com"]); }); it("should return an array with multiple emails if an array is provided", async () => { - const result = await getEmailsToInvite(["test1@example.com", "test2@example.com"]); + const result = await getUsernameOrEmailsToInvite(["test1@example.com", "test2@example.com"]); expect(result).toEqual(["test1@example.com", "test2@example.com"]); }); }); + describe("checkInputEmailIsValid", () => { + it("should throw a TRPCError with code BAD_REQUEST if the email is invalid", () => { + const invalidEmail = "invalid-email"; + expect(() => checkInputEmailIsValid(invalidEmail)).toThrow(TRPCError); + expect(() => checkInputEmailIsValid(invalidEmail)).toThrowError( + "Invite failed because invalid-email is not a valid email address" + ); + }); + it("should not throw an error if the email is valid", () => { + const validEmail = "valid-email@example.com"; + expect(() => checkInputEmailIsValid(validEmail)).not.toThrow(); + }); + }); describe("getOrgConnectionInfo", () => { const orgAutoAcceptDomain = "example.com"; const usersEmail = "user@example.com"; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 2ea1ed579c..1279996cb5 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -46,6 +46,14 @@ export async function checkPermissions({ } } +export function checkInputEmailIsValid(email: string) { + if (!isEmail(email)) + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invite failed because ${email} is not a valid email address`, + }); +} + export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { const team = await prisma.team.findFirst({ where: { @@ -62,7 +70,7 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { return team; } -export async function getEmailsToInvite(usernameOrEmail: string | string[]) { +export async function getUsernameOrEmailsToInvite(usernameOrEmail: string | string[]) { const emailsToInvite = Array.isArray(usernameOrEmail) ? Array.from(new Set(usernameOrEmail)) : [usernameOrEmail]; @@ -128,11 +136,11 @@ export function validateInviteeEligibility( } export async function getUsersToInvite({ - usernameOrEmail, + usernamesOrEmails, isInvitedToOrg, team, }: { - usernameOrEmail: string[]; + usernamesOrEmails: string[]; isInvitedToOrg: boolean; team: TeamWithParent; }): Promise { @@ -149,7 +157,7 @@ export async function getUsersToInvite({ const invitees: UserWithMembership[] = await prisma.user.findMany({ where: { - OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }], + OR: [{ username: { in: usernamesOrEmails }, ...orgWhere }, { email: { in: usernamesOrEmails } }], }, select: { id: true, @@ -217,6 +225,10 @@ export async function createNewUsersConnectToOrgIfExists({ autoAcceptEmailDomain?: string; connectionInfoMap: Record>; }) { + // fail if we have invalid emails + usernamesOrEmails.forEach((usernameOrEmail) => checkInputEmailIsValid(usernameOrEmail)); + + // from this point we know usernamesOrEmails contains only emails await prisma.$transaction( async (tx) => { for (let index = 0; index < usernamesOrEmails.length; index++) { From d0f7085cb86e1bcc0d425ca7e137c2ea76ab9287 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:21:12 -0500 Subject: [PATCH 3/8] fix: updating workflow with new step and new active event type (#12592) * correctly filter event types to avoid null values * clean up all filters * re-add reverted fix --------- Co-authored-by: CarinaWolli --- .../viewer/workflows/update.handler.ts | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index 76b05c083c..5799f96756 100644 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -130,18 +130,15 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { ) .flat(); - const newActiveEventTypes = activeOn.filter((eventType) => { - if ( + const newActiveEventTypes = activeOn.filter( + (eventType) => !oldActiveOnEventTypes || !oldActiveOnEventTypes .map((oldEventType) => { return oldEventType.eventTypeId; }) .includes(eventType) - ) { - return eventType; - } - }); + ); //check if new event types belong to user or team for (const newEventTypeId of newActiveEventTypes) { @@ -177,11 +174,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } //remove all scheduled Email and SMS reminders for eventTypes that are not active any more - const removedEventTypes = oldActiveOnEventTypeIds.filter((eventTypeId) => { - if (!activeOnWithChildren.includes(eventTypeId)) { - return eventTypeId; - } - }); + const removedEventTypes = oldActiveOnEventTypeIds.filter( + (eventTypeId) => !activeOnWithChildren.includes(eventTypeId) + ); const remindersToDeletePromise: Prisma.PrismaPromise< { @@ -471,11 +466,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }, }); //cancel all reminders of step and create new ones (not for newEventTypes) - const remindersToUpdate = remindersFromStep.filter((reminder) => { - if (reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId)) { - return reminder; - } - }); + const remindersToUpdate = remindersFromStep.filter( + (reminder) => reminder.booking?.eventTypeId && !newEventTypes.includes(reminder.booking?.eventTypeId) + ); //cancel all workflow reminders from steps that were edited // FIXME: async calls into ether @@ -488,11 +481,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId); } }); - const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => { - if (!newEventTypes.includes(eventTypeId)) { - return eventTypeId; - } - }); + + const eventTypesToUpdateReminders = activeOn.filter( + (eventTypeId) => !newEventTypes.includes(eventTypeId) + ); if ( eventTypesToUpdateReminders && (trigger === WorkflowTriggerEvents.BEFORE_EVENT || trigger === WorkflowTriggerEvents.AFTER_EVENT) @@ -629,11 +621,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); if (addedSteps) { - const eventTypesToCreateReminders = activeOn.map((activeEventType) => { - if (activeEventType && !newEventTypes.includes(activeEventType)) { - return activeEventType; - } - }); + const eventTypesToCreateReminders = activeOn.filter( + (activeEventType) => activeEventType && !newEventTypes.includes(activeEventType) + ); const promiseAddedSteps = addedSteps.map(async (step) => { if (step) { const { senderName, ...newStep } = step; From a65e18d92db102189aeed99b9d0c78d87ad34b89 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:44:48 +0200 Subject: [PATCH 4/8] chore: improve invitation form validation (#12594) * chore: improve invitation form validation * fixup! chore: improve invitation form validation * fixup! fixup! chore: improve invitation form validation --- apps/web/public/static/locales/en/common.json | 3 +++ .../components/MemberInvitationModal.tsx | 21 +++++++++++++++---- packages/lib/constants.ts | 3 +++ .../teams/inviteMember/inviteMember.schema.ts | 5 +++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index fad3de8546..de359414e0 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1552,6 +1552,9 @@ "member_already_invited": "Member has already been invited", "already_in_use_error": "Username already in use", "enter_email_or_username": "Enter an email or username", + "enter_email": "Enter an email", + "enter_emails": "Enter emails", + "too_many_invites": "You are limited to inviting a maximum of {{nbUsers}} users at once.", "team_name_taken": "This name is already taken", "must_enter_team_name": "Must enter a team name", "team_url_required": "Must enter a team URL", diff --git a/packages/features/ee/teams/components/MemberInvitationModal.tsx b/packages/features/ee/teams/components/MemberInvitationModal.tsx index bde9365f72..6d555450b8 100644 --- a/packages/features/ee/teams/components/MemberInvitationModal.tsx +++ b/packages/features/ee/teams/components/MemberInvitationModal.tsx @@ -6,11 +6,12 @@ import { Controller, useForm } from "react-hook-form"; import TeamInviteFromOrg from "@calcom/ee/organizations/components/TeamInviteFromOrg"; import { classNames } from "@calcom/lib"; -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED, MAX_NB_INVITES } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc"; import { trpc } from "@calcom/trpc"; +import { isEmail } from "@calcom/trpc/server/routers/viewer/teams/util"; import { Button, Dialog, @@ -199,7 +200,10 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) setModalInputMode(val as ModalMode)} + onValueChange={(val) => { + setModalInputMode(val as ModalMode); + newMemberFormMethods.clearErrors(); + }} defaultValue={modalImportMode} options={toggleGroupOptions} /> @@ -213,8 +217,10 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) name="emailOrUsername" control={newMemberFormMethods.control} rules={{ - required: t("enter_email_or_username"), + required: isOrg ? t("enter_email") : t("enter_email_or_username"), validate: (value) => { + // orgs can only invite members by email + if (typeof value === "string" && isOrg && !isEmail(value)) return t("enter_email"); if (typeof value === "string") return validateUniqueInvite(value) || t("member_already_invited"); }, @@ -241,7 +247,14 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps) name="emailOrUsername" control={newMemberFormMethods.control} rules={{ - required: t("enter_email_or_username"), + required: t("enter_email"), + validate: (value) => { + if (Array.isArray(value) && value.some((email) => !isEmail(email))) + return t("enter_emails"); + if (Array.isArray(value) && value.length > MAX_NB_INVITES) + return t("too_many_invites", { nbUsers: MAX_NB_INVITES }); + if (typeof value === "string" && !isEmail(value)) return t("enter_email"); + }, }} render={({ field: { onChange, value }, fieldState: { error } }) => ( <> diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 6f1a66ab9a..25cd09580c 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -119,3 +119,6 @@ export const AB_TEST_BUCKET_PROBABILITY = defaultOnNaN( export const IS_PREMIUM_USERNAME_ENABLED = (IS_CALCOM || (process.env.NEXT_PUBLIC_IS_E2E && IS_STRIPE_ENABLED)) && process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY; + +// Max number of invites to join a team/org that can be sent at once +export const MAX_NB_INVITES = 100; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts index 19f354a59e..f081508bc5 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { MAX_NB_INVITES } from "@calcom/lib/constants"; import { MembershipRole } from "@calcom/prisma/enums"; export const ZInviteMemberInputSchema = z.object({ @@ -15,13 +16,13 @@ export const ZInviteMemberInputSchema = z.object({ .refine( (value) => { if (Array.isArray(value)) { - if (value.length > 100) { + if (value.length > MAX_NB_INVITES) { return false; } } return true; }, - { message: "You are limited to inviting a maximum of 100 users at once." } + { message: `You are limited to inviting a maximum of ${MAX_NB_INVITES} users at once.` } ) .refine( (value) => { From 33250652b31c02920eb5792f3b4c6f070bb148bf Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 29 Nov 2023 14:39:59 -0700 Subject: [PATCH 5/8] Double e2e shards --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0c084aea3c..aaa218103b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout From 7f23ae156b4c263a4722adf688a2bd1c15a37aee Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 29 Nov 2023 21:48:26 +0000 Subject: [PATCH 6/8] fix: improved team upgrade screen to also show unpublished teams (#12492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improved team upgrade screen to also show unpublished teams * Update TeamsListing.tsx * bunch of stuff --------- Co-authored-by: Omar López --- .../features/ee/teams/components/TeamList.tsx | 16 - .../ee/teams/components/TeamListItem.tsx | 56 +- .../ee/teams/components/TeamsListing.tsx | 1 - packages/features/tips/UpgradeTip.tsx | 9 +- .../server/routers/viewer/teams/_router.tsx | 538 ++++-------------- .../viewer/teams/acceptOrLeave.handler.ts | 2 + .../viewer/teams/changeMemberRole.handler.ts | 2 + .../routers/viewer/teams/create.handler.ts | 2 + .../viewer/teams/createInvite.handler.ts | 2 + .../routers/viewer/teams/delete.handler.ts | 2 + .../viewer/teams/deleteInvite.handler.ts | 2 + .../routers/viewer/teams/get.handler.ts | 2 + .../teams/getMemberAvailability.handler.ts | 2 + .../teams/getMembershipbyUser.handler.ts | 2 + .../viewer/teams/getUpgradeable.handler.ts | 2 + .../teams/hasEditPermissionForUser.handler.ts | 2 + .../viewer/teams/hasTeamPlan.handler.ts | 2 + .../inviteMember/inviteMember.handler.ts | 2 + .../teams/inviteMemberByToken.handler.ts | 2 + .../routers/viewer/teams/list.handler.ts | 2 + .../viewer/teams/listInvites.handler.ts | 2 + .../viewer/teams/listMembers.handler.ts | 2 + .../routers/viewer/teams/publish.handler.ts | 2 + .../viewer/teams/removeMember.handler.ts | 2 + .../viewer/teams/resendInvitation.handler.ts | 2 + .../teams/setInviteExpiration.handler.ts | 2 + .../routers/viewer/teams/update.handler.ts | 2 + .../viewer/teams/updateMembership.handler.ts | 2 + 28 files changed, 207 insertions(+), 459 deletions(-) diff --git a/packages/features/ee/teams/components/TeamList.tsx b/packages/features/ee/teams/components/TeamList.tsx index 9cd893b95d..ee2918e80d 100644 --- a/packages/features/ee/teams/components/TeamList.tsx +++ b/packages/features/ee/teams/components/TeamList.tsx @@ -75,22 +75,6 @@ export default function TeamList(props: Props) { child: t("invite"), }} /> - {/* @TODO: uncomment once managed event types is live - } - variant="basic" - title={t("create_a_managed_event")} - description={t("create_a_one_one_template")} - actionButton={{ - href: - "/event-types?dialog=new-eventtype&eventPage=team%2F" + - team.slug + - "&teamId=" + - team.id + - "&managed=true", - child: t("create"), - }} - /> */} } variant="basic" diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index 6be6a2344d..047695419f 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -13,6 +13,7 @@ import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Avatar, + Badge, Button, ButtonGroup, ConfirmationDialogContent, @@ -104,11 +105,15 @@ export default function TeamListItem(props: Props) {
{team.name} - {team.slug - ? orgBranding - ? `${orgBranding.fullDomain}/${team.slug}` - : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` - : "Unpublished team"} + {team.slug ? ( + orgBranding ? ( + `${orgBranding.fullDomain}/${team.slug}` + ) : ( + `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` + ) + ) : ( + {t("upgrade")} + )}
@@ -180,13 +185,17 @@ export default function TeamListItem(props: Props) { )}
{!isInvitee ? ( - - {teamInfo} - + team.slug ? ( + + {teamInfo} + + ) : ( + {teamInfo} + ) ) : ( teamInfo )} @@ -388,3 +397,26 @@ const TeamPublishButton = ({ teamId }: { teamId: number }) => { ); }; + +const TeamPublishSection = ({ children, teamId }: { children: React.ReactNode; teamId: number }) => { + const router = useRouter(); + const publishTeamMutation = trpc.viewer.teams.publish.useMutation({ + onSuccess(data) { + router.push(data.url); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + return ( + + ); +}; diff --git a/packages/features/ee/teams/components/TeamsListing.tsx b/packages/features/ee/teams/components/TeamsListing.tsx index 5b909484c7..53fa8b4b94 100644 --- a/packages/features/ee/teams/components/TeamsListing.tsx +++ b/packages/features/ee/teams/components/TeamsListing.tsx @@ -102,7 +102,6 @@ export function TeamsListing() {
)} - {title} `${NAMESPACE}.${s}`; export const viewerTeamsRouter = router({ // Retrieves team by id - get: authedProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.get) { - UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.get) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.get({ - ctx, - input, - }); + get: authedProcedure.input(ZGetInputSchema).query(async (opts) => { + const handler = await importHandler(namespaced("get"), () => import("./get.handler")); + return handler(opts); }), - // Returns teams I a member of - list: authedProcedure.query(async ({ ctx }) => { - if (!UNSTABLE_HANDLER_CACHE.list) { - UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.list) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.list({ - ctx, - }); + list: authedProcedure.query(async (opts) => { + const handler = await importHandler(namespaced("list"), () => import("./list.handler")); + return handler(opts); }), // Returns Teams I am a owner/admin of - listOwnedTeams: authedProcedure.query(async ({ ctx }) => { - if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) { - UNSTABLE_HANDLER_CACHE.listOwnedTeams = await import("./listOwnedTeams.handler").then( - (mod) => mod.listOwnedTeamsHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.listOwnedTeams({ - ctx, - }); + listOwnedTeams: authedProcedure.query(async (opts) => { + const handler = await importHandler(namespaced("list"), () => import("./list.handler")); + return handler(opts); }), - - create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.create) { - UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.create) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.create({ - ctx, - input, - }); + create: authedProcedure.input(ZCreateInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("create"), () => import("./create.handler")); + return handler(opts); }), - // Allows team owner to update team metadata - update: authedProcedure.input(ZUpdateInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.update) { - UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.update) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.update({ - ctx, - input, - }); + update: authedProcedure.input(ZUpdateInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("update"), () => import("./update.handler")); + return handler(opts); }), - - delete: authedProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.delete) { - UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.delete) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.delete({ - ctx, - input, - }); + delete: authedProcedure.input(ZDeleteInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("delete"), () => import("./delete.handler")); + return handler(opts); }), - - removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.removeMember) { - UNSTABLE_HANDLER_CACHE.removeMember = await import("./removeMember.handler").then( - (mod) => mod.removeMemberHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.removeMember) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.removeMember({ - ctx, - input, - }); + removeMember: authedProcedure.input(ZRemoveMemberInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("removeMember"), () => import("./removeMember.handler")); + return handler(opts); }), - - inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.inviteMember) { - UNSTABLE_HANDLER_CACHE.inviteMember = await import("./inviteMember/inviteMember.handler").then( - (mod) => mod.inviteMemberHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.inviteMember) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.inviteMember({ - ctx, - input, - }); + inviteMember: authedProcedure.input(ZInviteMemberInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("inviteMember"), + () => import("./inviteMember/inviteMember.handler") + ); + return handler(opts); }), - - acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { - UNSTABLE_HANDLER_CACHE.acceptOrLeave = await import("./acceptOrLeave.handler").then( - (mod) => mod.acceptOrLeaveHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.acceptOrLeave) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.acceptOrLeave({ - ctx, - input, - }); + acceptOrLeave: authedProcedure.input(ZAcceptOrLeaveInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("acceptOrLeave"), () => import("./acceptOrLeave.handler")); + return handler(opts); }), - - changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { - UNSTABLE_HANDLER_CACHE.changeMemberRole = await import("./changeMemberRole.handler").then( - (mod) => mod.changeMemberRoleHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.changeMemberRole) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.changeMemberRole({ - ctx, - input, - }); + changeMemberRole: authedProcedure.input(ZChangeMemberRoleInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("changeMemberRole"), + () => import("./changeMemberRole.handler") + ); + return handler(opts); }), - - getMemberAvailability: authedProcedure - .input(ZGetMemberAvailabilityInputSchema) - .query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { - UNSTABLE_HANDLER_CACHE.getMemberAvailability = await import("./getMemberAvailability.handler").then( - (mod) => mod.getMemberAvailabilityHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.getMemberAvailability) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.getMemberAvailability({ - ctx, - input, - }); - }), - - getMembershipbyUser: authedProcedure - .input(ZGetMembershipbyUserInputSchema) - .query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { - UNSTABLE_HANDLER_CACHE.getMembershipbyUser = await import("./getMembershipbyUser.handler").then( - (mod) => mod.getMembershipbyUserHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.getMembershipbyUser) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.getMembershipbyUser({ - ctx, - input, - }); - }), - - updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.updateMembership) { - UNSTABLE_HANDLER_CACHE.updateMembership = await import("./updateMembership.handler").then( - (mod) => mod.updateMembershipHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.updateMembership) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.updateMembership({ - ctx, - input, - }); + getMemberAvailability: authedProcedure.input(ZGetMemberAvailabilityInputSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("getMemberAvailability"), + () => import("./getMemberAvailability.handler") + ); + return handler(opts); }), - - publish: authedProcedure.input(ZPublishInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.publish) { - UNSTABLE_HANDLER_CACHE.publish = await import("./publish.handler").then((mod) => mod.publishHandler); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.publish) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.publish({ - ctx, - input, - }); + getMembershipbyUser: authedProcedure.input(ZGetMembershipbyUserInputSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("getMembershipbyUser"), + () => import("./getMembershipbyUser.handler") + ); + return handler(opts); + }), + updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("updateMembership"), + () => import("./updateMembership.handler") + ); + return handler(opts); + }), + publish: authedProcedure.input(ZPublishInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("publish"), () => import("./publish.handler")); + return handler(opts); }), - /** This is a temporal endpoint so we can progressively upgrade teams to the new billing system. */ - getUpgradeable: authedProcedure.query(async ({ ctx }) => { - if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { - UNSTABLE_HANDLER_CACHE.getUpgradeable = await import("./getUpgradeable.handler").then( - (mod) => mod.getUpgradeableHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.getUpgradeable) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.getUpgradeable({ - ctx, - }); + getUpgradeable: authedProcedure.query(async (opts) => { + const handler = await importHandler( + namespaced("getUpgradeable"), + () => import("./getUpgradeable.handler") + ); + return handler(opts); }), - - listMembers: authedProcedure.input(ZListMembersInputSchema).query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.listMembers) { - UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then( - (mod) => mod.listMembersHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.listMembers) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.listMembers({ - ctx, - input, - }); + listMembers: authedProcedure.input(ZListMembersInputSchema).query(async (opts) => { + const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler")); + return handler(opts); }), - - hasTeamPlan: authedProcedure.query(async ({ ctx }) => { - if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { - UNSTABLE_HANDLER_CACHE.hasTeamPlan = await import("./hasTeamPlan.handler").then( - (mod) => mod.hasTeamPlanHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.hasTeamPlan) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.hasTeamPlan({ - ctx, - }); + hasTeamPlan: authedProcedure.query(async (opts) => { + const handler = await importHandler(namespaced("hasTeamPlan"), () => import("./hasTeamPlan.handler")); + return handler(opts); }), - - listInvites: authedProcedure.query(async ({ ctx }) => { - if (!UNSTABLE_HANDLER_CACHE.listInvites) { - UNSTABLE_HANDLER_CACHE.listInvites = await import("./listInvites.handler").then( - (mod) => mod.listInvitesHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.listInvites) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.listInvites({ - ctx, - }); + listInvites: authedProcedure.query(async (opts) => { + const handler = await importHandler(namespaced("listInvites"), () => import("./listInvites.handler")); + return handler(opts); }), - - createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.createInvite) { - UNSTABLE_HANDLER_CACHE.createInvite = await import("./createInvite.handler").then( - (mod) => mod.createInviteHandler - ); - } - - if (!UNSTABLE_HANDLER_CACHE.createInvite) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.createInvite({ - ctx, - input, - }); + createInvite: authedProcedure.input(ZCreateInviteInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("createInvite"), () => import("./createInvite.handler")); + return handler(opts); }), - - setInviteExpiration: authedProcedure - .input(ZSetInviteExpirationInputSchema) - .mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) { - UNSTABLE_HANDLER_CACHE.setInviteExpiration = await import("./setInviteExpiration.handler").then( - (mod) => mod.setInviteExpirationHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.setInviteExpiration) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.setInviteExpiration({ - ctx, - input, - }); - }), - deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.deleteInvite) { - UNSTABLE_HANDLER_CACHE.deleteInvite = await import("./deleteInvite.handler").then( - (mod) => mod.deleteInviteHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.deleteInvite) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.deleteInvite({ - ctx, - input, - }); + setInviteExpiration: authedProcedure.input(ZSetInviteExpirationInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("setInviteExpiration"), + () => import("./setInviteExpiration.handler") + ); + return handler(opts); }), - inviteMemberByToken: authedProcedure - .input(ZInviteMemberByTokenSchemaInputSchema) - .mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) { - UNSTABLE_HANDLER_CACHE.inviteMemberByToken = await import("./inviteMemberByToken.handler").then( - (mod) => mod.inviteMemberByTokenHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.inviteMemberByToken) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.inviteMemberByToken({ - ctx, - input, - }); - }), - hasEditPermissionForUser: authedProcedure - .input(ZHasEditPermissionForUserSchema) - .query(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser) { - UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser = await import( - "./hasEditPermissionForUser.handler" - ).then((mod) => mod.hasEditPermissionForUser); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.hasEditPermissionForUser({ - ctx, - input, - }); - }), - resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async ({ ctx, input }) => { - if (!UNSTABLE_HANDLER_CACHE.resendInvitation) { - UNSTABLE_HANDLER_CACHE.resendInvitation = await import("./resendInvitation.handler").then( - (mod) => mod.resendInvitationHandler - ); - } - - // Unreachable code but required for type safety - if (!UNSTABLE_HANDLER_CACHE.resendInvitation) { - throw new Error("Failed to load handler"); - } - - return UNSTABLE_HANDLER_CACHE.resendInvitation({ - ctx, - input, - }); + deleteInvite: authedProcedure.input(ZDeleteInviteInputSchema).mutation(async (opts) => { + const handler = await importHandler(namespaced("deleteInvite"), () => import("./deleteInvite.handler")); + return handler(opts); + }), + inviteMemberByToken: authedProcedure.input(ZInviteMemberByTokenSchemaInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("inviteMemberByToken"), + () => import("./inviteMemberByToken.handler") + ); + return handler(opts); + }), + hasEditPermissionForUser: authedProcedure.input(ZHasEditPermissionForUserSchema).query(async (opts) => { + const handler = await importHandler( + namespaced("hasEditPermissionForUser"), + () => import("./hasEditPermissionForUser.handler") + ); + return handler(opts); + }), + resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async (opts) => { + const handler = await importHandler( + namespaced("resendInvitation"), + () => import("./resendInvitation.handler") + ); + return handler(opts); }), }); diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts index d15e4dba47..9e25f31a02 100644 --- a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -48,3 +48,5 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) } } }; + +export default acceptOrLeaveHandler; diff --git a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts index f2a6f23861..290a1be923 100644 --- a/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts @@ -71,3 +71,5 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp // Sync Services: Close.com closeComUpsertTeamUser(membership.team, membership.user, membership.role); }; + +export default changeMemberRoleHandler; diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 4c3ffffd18..77e20a5f13 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -117,3 +117,5 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { team: createdTeam, }; }; + +export default createHandler; diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts index 380882ab55..1dc192b300 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts @@ -59,3 +59,5 @@ async function getInviteLink(token = "", isOrg = false, orgMembers = 0) { if (isOrg || orgMembers > 0) return orgInviteLink; return teamInviteLink; } + +export default createInviteHandler; diff --git a/packages/trpc/server/routers/viewer/teams/delete.handler.ts b/packages/trpc/server/routers/viewer/teams/delete.handler.ts index 29d7d07c17..26c0f2dbe1 100644 --- a/packages/trpc/server/routers/viewer/teams/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/delete.handler.ts @@ -82,3 +82,5 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { // Sync Services: Close.cm closeComDeleteTeam(deletedTeam); }; + +export default deleteHandler; diff --git a/packages/trpc/server/routers/viewer/teams/deleteInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/deleteInvite.handler.ts index 5a92545dcb..78d2c9ca20 100644 --- a/packages/trpc/server/routers/viewer/teams/deleteInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/deleteInvite.handler.ts @@ -31,3 +31,5 @@ export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) = await prisma.verificationToken.delete({ where: { id: verificationToken.id } }); }; + +export default deleteInviteHandler; diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index 6cc6c52db5..ca6256d448 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -36,3 +36,5 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { }, }; }; + +export default getHandler; diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts index 3545e6e321..0d7922a0dc 100644 --- a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -54,3 +54,5 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai { user: member.user } ); }; + +export default getMemberAvailabilityHandler; diff --git a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts index 55794cb47f..eeedb362aa 100644 --- a/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getMembershipbyUser.handler.ts @@ -29,3 +29,5 @@ export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipby }, }); }; + +export default getMembershipbyUserHandler; diff --git a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts index 7587867e34..941c77a11e 100644 --- a/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getUpgradeable.handler.ts @@ -43,3 +43,5 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => { }); return teams; }; + +export default getUpgradeableHandler; diff --git a/packages/trpc/server/routers/viewer/teams/hasEditPermissionForUser.handler.ts b/packages/trpc/server/routers/viewer/teams/hasEditPermissionForUser.handler.ts index 882e922e30..ae5b9bc66c 100644 --- a/packages/trpc/server/routers/viewer/teams/hasEditPermissionForUser.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasEditPermissionForUser.handler.ts @@ -17,3 +17,5 @@ export const hasEditPermissionForUser = async ({ ctx, input }: HasEditPermission input, }); }; + +export default hasEditPermissionForUser; diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index 882876515c..f8c757b624 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -23,3 +23,5 @@ export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { }); return { hasTeamPlan: !!hasTeamPlan }; }; + +export default hasTeamPlanHandler; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index deb8a55019..35e882bdf8 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -140,3 +140,5 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = } return input; }; + +export default inviteMemberHandler; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMemberByToken.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMemberByToken.handler.ts index 5518a18ec1..5f1d533c31 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMemberByToken.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMemberByToken.handler.ts @@ -64,3 +64,5 @@ export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByT return verificationToken.team.name; }; + +export default inviteMemberByTokenHandler; diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts index 44220804aa..31a53fdd48 100644 --- a/packages/trpc/server/routers/viewer/teams/list.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -41,3 +41,5 @@ export const listHandler = async ({ ctx }: ListOptions) => { inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`), })); }; + +export default listHandler; diff --git a/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts index d4ffed6217..5d266f692b 100644 --- a/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listInvites.handler.ts @@ -18,3 +18,5 @@ export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => { }, }); }; + +export default listInvitesHandler; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts index 29291c5723..8def528dca 100644 --- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -54,3 +54,5 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) => return Object.values(users); }; + +export default listMembersHandler; diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index 5bb5dbb195..48e2e6e781 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -157,3 +157,5 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => { message: "Team published successfully", }; }; + +export default publishHandler; diff --git a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts index 96b7b3cc9d..31927231ef 100644 --- a/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/removeMember.handler.ts @@ -121,3 +121,5 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) = closeComDeleteTeamMembership(membership.user); if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId); }; + +export default removeMemberHandler; diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index a7c0cc9bd5..e7a58bd9ca 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -58,3 +58,5 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption return input; }; + +export default resendInvitationHandler; diff --git a/packages/trpc/server/routers/viewer/teams/setInviteExpiration.handler.ts b/packages/trpc/server/routers/viewer/teams/setInviteExpiration.handler.ts index cbba00e2f1..28d0ba4676 100644 --- a/packages/trpc/server/routers/viewer/teams/setInviteExpiration.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/setInviteExpiration.handler.ts @@ -39,3 +39,5 @@ export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpira }, }); }; + +export default setInviteExpirationHandler; diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index ff5692f0ac..bcb9fbce9e 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -97,3 +97,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { darkBrandColor: updatedTeam.darkBrandColor, }; }; + +export default updateHandler; diff --git a/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts index 9a1b1cac9d..4b42b4a53f 100644 --- a/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/updateMembership.handler.ts @@ -32,3 +32,5 @@ export const updateMembershipHandler = async ({ ctx, input }: UpdateMembershipOp }, }); }; + +export default updateMembershipHandler; From c11ecbb323107a3d9ee9325bab074e7d35828efb Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 29 Nov 2023 22:51:47 +0000 Subject: [PATCH 7/8] Fix:use typed query initially fill default paramaters (#12568) --- packages/lib/hooks/useTypedQuery.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/lib/hooks/useTypedQuery.ts b/packages/lib/hooks/useTypedQuery.ts index 7fb61414d0..974c0ede25 100644 --- a/packages/lib/hooks/useTypedQuery.ts +++ b/packages/lib/hooks/useTypedQuery.ts @@ -1,5 +1,5 @@ import { usePathname, useRouter } from "next/navigation"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useEffect } from "react"; import { z } from "zod"; import { useRouterQuery } from "./useRouterQuery"; @@ -46,6 +46,17 @@ export function useTypedQuery(schema: T) { return {} as Output; }, []); + useEffect(() => { + if (parsedQuerySchema.success && parsedQuerySchema.data) { + Object.entries(parsedQuerySchema.data).forEach(([key, value]) => { + if (key in unparsedQuery || !value) return; + const search = new URLSearchParams(parsedQuery); + search.set(String(key), String(value)); + router.replace(`${pathname}?${search.toString()}`); + }); + } + }, [parsedQuerySchema, schema, router, pathname, unparsedQuery, parsedQuery]); + if (parsedQuerySchema.success) parsedQuery = parsedQuerySchema.data; else if (!parsedQuerySchema.success) console.error(parsedQuerySchema.error); From 56050b994d5944c9aa44b85b4a54d51cb058c3be Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:31:59 -0500 Subject: [PATCH 8/8] fix 'attempt booking in the past' error (#12597) Co-authored-by: CarinaWolli Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- packages/trpc/server/routers/viewer/slots/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index fbd63249b1..882a2281d0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -292,7 +292,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { }` ); const getStartTime = (startTimeInput: string, timeZone?: string) => { - const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes"); + const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice || 1, "minutes"); const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone); return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;