Merge branch 'main' into testE2E-timezone

This commit is contained in:
GitStart-Cal.com 2023-11-30 23:26:14 +05:45 committed by GitHub
commit f03743eee4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 692 additions and 705 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
</Label>
<ToggleGroup
isFullWidth={true}
onValueChange={(val) => 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 } }) => (
<>

View File

@ -75,22 +75,6 @@ export default function TeamList(props: Props) {
child: t("invite"),
}}
/>
{/* @TODO: uncomment once managed event types is live
<Card
icon={<Unlock className="h-5 w-5 text-blue-700" />}
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"),
}}
/> */}
<Card
icon={<Users className="h-5 w-5 text-orange-700" />}
variant="basic"

View File

@ -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) {
<div className="ms-3 inline-block truncate">
<span className="text-default text-sm font-bold">{team.name}</span>
<span className="text-muted block text-xs">
{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}`
)
) : (
<Badge>{t("upgrade")}</Badge>
)}
</span>
</div>
</div>
@ -180,13 +185,17 @@ export default function TeamListItem(props: Props) {
)}
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
{!isInvitee ? (
<Link
data-testid="team-list-item-link"
href={`/settings/teams/${team.id}/profile`}
className="flex-grow cursor-pointer truncate text-sm"
title={`${team.name}`}>
{teamInfo}
</Link>
team.slug ? (
<Link
data-testid="team-list-item-link"
href={`/settings/teams/${team.id}/profile`}
className="flex-grow cursor-pointer truncate text-sm"
title={`${team.name}`}>
{teamInfo}
</Link>
) : (
<TeamPublishSection teamId={team.id}>{teamInfo}</TeamPublishSection>
)
) : (
teamInfo
)}
@ -388,3 +397,26 @@ const TeamPublishButton = ({ teamId }: { teamId: number }) => {
</DropdownMenuItem>
);
};
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 (
<button
className="block flex-grow cursor-pointer truncate text-left text-sm"
type="button"
onClick={() => {
publishTeamMutation.mutate({ teamId });
}}>
{children}
</button>
);
};

View File

@ -102,7 +102,6 @@ export function TeamsListing() {
<TeamList teams={invites} pending />
</div>
)}
<UpgradeTip
plan="team"
title={t("calcom_is_better_with_team", { appName: APP_NAME })}

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { classNames } from "@calcom/lib";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
export function UpgradeTip({
dark,
@ -29,10 +30,14 @@ export function UpgradeTip({
}) {
const { t } = useLocale();
const { isLoading, hasTeamPlan } = useHasTeamPlan();
const { data } = trpc.viewer.teams.getUpgradeable.useQuery();
const hasEnterprisePlan = false;
//const { isLoading , hasEnterprisePlan } = useHasEnterprisePlan();
if (plan === "team" && hasTeamPlan) return children;
const hasUnpublishedTeam = !!data?.[0];
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return children;
if (plan === "enterprise" && hasEnterprisePlan) return children;
@ -44,7 +49,7 @@ export function UpgradeTip({
<picture className="absolute min-h-[295px] w-full rounded-lg object-cover">
<source srcSet={`${background}-dark.jpg`} media="(prefers-color-scheme: dark)" />
<img
className="absolute min-h-[295px] w-full rounded-lg object-cover object-left md:object-center select-none"
className="absolute min-h-[295px] w-full select-none rounded-lg object-cover object-left md:object-center"
src={`${background}.jpg`}
loading="lazy"
alt={title}

View File

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

View File

@ -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<T extends z.AnyZodObject>(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);

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { importHandler, router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCreateInputSchema } from "./create.schema";
@ -20,457 +20,139 @@ import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
type TeamsRouterHandlerCache = {
get?: typeof import("./get.handler").getHandler;
list?: typeof import("./list.handler").listHandler;
listOwnedTeams?: typeof import("./listOwnedTeams.handler").listOwnedTeamsHandler;
create?: typeof import("./create.handler").createHandler;
update?: typeof import("./update.handler").updateHandler;
delete?: typeof import("./delete.handler").deleteHandler;
removeMember?: typeof import("./removeMember.handler").removeMemberHandler;
inviteMember?: typeof import("./inviteMember/inviteMember.handler").inviteMemberHandler;
acceptOrLeave?: typeof import("./acceptOrLeave.handler").acceptOrLeaveHandler;
changeMemberRole?: typeof import("./changeMemberRole.handler").changeMemberRoleHandler;
getMemberAvailability?: typeof import("./getMemberAvailability.handler").getMemberAvailabilityHandler;
getMembershipbyUser?: typeof import("./getMembershipbyUser.handler").getMembershipbyUserHandler;
updateMembership?: typeof import("./updateMembership.handler").updateMembershipHandler;
publish?: typeof import("./publish.handler").publishHandler;
getUpgradeable?: typeof import("./getUpgradeable.handler").getUpgradeableHandler;
listMembers?: typeof import("./listMembers.handler").listMembersHandler;
hasTeamPlan?: typeof import("./hasTeamPlan.handler").hasTeamPlanHandler;
listInvites?: typeof import("./listInvites.handler").listInvitesHandler;
createInvite?: typeof import("./createInvite.handler").createInviteHandler;
setInviteExpiration?: typeof import("./setInviteExpiration.handler").setInviteExpirationHandler;
deleteInvite?: typeof import("./deleteInvite.handler").deleteInviteHandler;
inviteMemberByToken?: typeof import("./inviteMemberByToken.handler").inviteMemberByTokenHandler;
hasEditPermissionForUser?: typeof import("./hasEditPermissionForUser.handler").hasEditPermissionForUser;
resendInvitation?: typeof import("./resendInvitation.handler").resendInvitationHandler;
};
const UNSTABLE_HANDLER_CACHE: TeamsRouterHandlerCache = {};
const NAMESPACE = "teams";
const namespaced = (s: string) => `${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);
}),
});

View File

@ -48,3 +48,5 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions)
}
}
};
export default acceptOrLeaveHandler;

View File

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

View File

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

View File

@ -59,3 +59,5 @@ async function getInviteLink(token = "", isOrg = false, orgMembers = 0) {
if (isOrg || orgMembers > 0) return orgInviteLink;
return teamInviteLink;
}
export default createInviteHandler;

View File

@ -82,3 +82,5 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
// Sync Services: Close.cm
closeComDeleteTeam(deletedTeam);
};
export default deleteHandler;

View File

@ -31,3 +31,5 @@ export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) =
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
};
export default deleteInviteHandler;

View File

@ -36,3 +36,5 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
},
};
};
export default getHandler;

View File

@ -54,3 +54,5 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai
{ user: member.user }
);
};
export default getMemberAvailabilityHandler;

View File

@ -29,3 +29,5 @@ export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipby
},
});
};
export default getMembershipbyUserHandler;

View File

@ -43,3 +43,5 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
});
return teams;
};
export default getUpgradeableHandler;

View File

@ -17,3 +17,5 @@ export const hasEditPermissionForUser = async ({ ctx, input }: HasEditPermission
input,
});
};
export default hasEditPermissionForUser;

View File

@ -23,3 +23,5 @@ export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
});
return { hasTeamPlan: !!hasTeamPlan };
};
export default hasTeamPlanHandler;

View File

@ -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<string, ReturnType<typeof getOrgConnectionInfo>>);
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);
@ -129,3 +140,5 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
}
return input;
};
export default inviteMemberHandler;

View File

@ -1,9 +1,8 @@
import { z } from "zod";
import { MAX_NB_INVITES } from "@calcom/lib/constants";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
export const ZInviteMemberInputSchema = z.object({
teamId: z.number(),
usernameOrEmail: z
@ -14,27 +13,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 > MAX_NB_INVITES) {
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 ${MAX_NB_INVITES} 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),

View File

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

View File

@ -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<UserWithMembership[]> {
@ -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<string, ReturnType<typeof getOrgConnectionInfo>>;
}) {
// 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++) {

View File

@ -64,3 +64,5 @@ export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByT
return verificationToken.team.name;
};
export default inviteMemberByTokenHandler;

View File

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

View File

@ -18,3 +18,5 @@ export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => {
},
});
};
export default listInvitesHandler;

View File

@ -54,3 +54,5 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
return Object.values(users);
};
export default listMembersHandler;

View File

@ -157,3 +157,5 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => {
message: "Team published successfully",
};
};
export default publishHandler;

View File

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

View File

@ -58,3 +58,5 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption
return input;
};
export default resendInvitationHandler;

View File

@ -39,3 +39,5 @@ export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpira
},
});
};
export default setInviteExpirationHandler;

View File

@ -97,3 +97,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
darkBrandColor: updatedTeam.darkBrandColor,
};
};
export default updateHandler;

View File

@ -32,3 +32,5 @@ export const updateMembershipHandler = async ({ ctx, input }: UpdateMembershipOp
},
});
};
export default updateMembershipHandler;

View File

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

View File

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