Merge branch 'main' into testE2E-timezone
This commit is contained in:
commit
f03743eee4
|
@ -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
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
const querySchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
});
|
||||
|
||||
const checkoutSessionMetadataSchema = z.object({
|
||||
teamName: z.string(),
|
||||
teamSlug: z.string(),
|
||||
userId: z.string().transform(Number),
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { session_id } = querySchema.parse(req.query);
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
|
||||
expand: ["subscription"],
|
||||
});
|
||||
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
||||
|
||||
const subscription = checkoutSession.subscription as Stripe.Subscription;
|
||||
|
||||
if (checkoutSession.payment_status !== "paid")
|
||||
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
||||
|
||||
// Let's query to ensure that the team metadata carried over from the checkout session.
|
||||
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
|
||||
|
||||
if (!parseCheckoutSessionMetadata.success) {
|
||||
console.error(
|
||||
"Team metadata not found in checkout session",
|
||||
parseCheckoutSessionMetadata.error,
|
||||
checkoutSession.id
|
||||
);
|
||||
}
|
||||
|
||||
if (!checkoutSession.metadata?.userId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Can't publish team/org without userId",
|
||||
});
|
||||
}
|
||||
|
||||
const checkoutSessionMetadata = parseCheckoutSessionMetadata.success
|
||||
? parseCheckoutSessionMetadata.data
|
||||
: {
|
||||
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
|
||||
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
|
||||
userId: checkoutSession.metadata.userId,
|
||||
};
|
||||
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: checkoutSessionMetadata.teamName,
|
||||
slug: checkoutSessionMetadata.teamSlug,
|
||||
members: {
|
||||
create: {
|
||||
userId: checkoutSessionMetadata.userId as number,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: subscription.id || null,
|
||||
subscriptionItemId: subscription.items.data[0].id || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Services: Close.com
|
||||
// closeComUpdateTeam(prevTeam, team);
|
||||
|
||||
// redirect to team screen
|
||||
res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`);
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
|
@ -24,7 +24,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
|||
// there should be one, otherwise we throw
|
||||
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -37,7 +37,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
|||
// Test when a user changes his email after starting the password reset flow
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}-2@example.com`,
|
||||
|
@ -54,7 +54,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
|||
email: `${user.username}-2@example.com`,
|
||||
},
|
||||
data: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -75,7 +75,7 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
|||
// we're not logging in to the UI to speed up test performance.
|
||||
const updatedUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
email: `${user.username}@example.com`,
|
||||
email: user.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
@ -84,10 +84,10 @@ test("Can reset forgotten password", async ({ page, users }) => {
|
|||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||
|
||||
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
|
||||
await page.goto(`/auth/forgot-password/${id}`);
|
||||
|
||||
await page.waitForSelector("text=That request is expired.");
|
||||
await expect(page.locator(`text=Whoops`)).toBeVisible();
|
||||
});
|
||||
|
|
|
@ -295,3 +295,11 @@ export function generateTotpCode(email: string) {
|
|||
totp.options = { step: 90 };
|
||||
return totp.generate(secret);
|
||||
}
|
||||
|
||||
export async function fillStripeTestCheckout(page: Page) {
|
||||
await page.fill("[name=cardNumber]", "4242424242424242");
|
||||
await page.fill("[name=cardExpiry]", "12/30");
|
||||
await page.fill("[name=cardCvc]", "111");
|
||||
await page.fill("[name=billingName]", "Stripe Stripeson");
|
||||
await page.click(".SubmitButton--complete-Shimmer");
|
||||
}
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
import { localize } from "./lib/testUtils";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
fillStripeTestCheckout,
|
||||
localize,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
|
@ -21,18 +27,20 @@ test.describe("Managed Event Types tests", () => {
|
|||
|
||||
await test.step("Managed event option exists for team admin", async () => {
|
||||
// Filling team creation form wizard
|
||||
await page.locator('input[name="name"]').waitFor();
|
||||
await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`);
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
// wait for the second member to be added to the pending-member-list.
|
||||
await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor();
|
||||
// and publish
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL("/settings/teams/**");
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
// Going to create an event type
|
||||
await page.goto("/event-types");
|
||||
await page.getByTestId("new-event-type").click();
|
||||
|
|
|
@ -1,34 +1,32 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
fillStripeTestCheckout,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
testName,
|
||||
todo,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Teams - NonOrg", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create Team").click();
|
||||
await page.waitForURL("/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
test("Team Onboarding Invite Members", async ({ page, users }) => {
|
||||
const user = await users.create(undefined, { hasTeam: true });
|
||||
const { team } = await user.getFirstTeam();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
||||
await user.apiLogin();
|
||||
|
||||
page.goto(`/settings/teams/${team.id}/onboard-members`);
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
|
@ -50,9 +48,9 @@ test.describe("Teams - NonOrg", () => {
|
|||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can publish team", async () => {
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await test.step("Finishing brings you to team profile page", async () => {
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
|
@ -66,7 +64,6 @@ test.describe("Teams - NonOrg", () => {
|
|||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
|
@ -74,11 +71,14 @@ test.describe("Teams - NonOrg", () => {
|
|||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
|
@ -102,18 +102,20 @@ test.describe("Teams - NonOrg", () => {
|
|||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
@ -134,7 +136,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
|
@ -164,8 +166,7 @@ test.describe("Teams - NonOrg", () => {
|
|||
await page.goto("/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
await page.locator("text=Continue").click();
|
||||
await expect(page.locator("[data-testid=alert]")).toBeVisible();
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// cleanup
|
||||
const org = await owner.getOrgMembership();
|
||||
|
@ -174,11 +175,9 @@ test.describe("Teams - NonOrg", () => {
|
|||
});
|
||||
|
||||
test("Can create team with same name as user", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
const ownerObj = { username: uniqueName, name: uniqueName, useExactUsername: true };
|
||||
|
||||
const user = await users.create(ownerObj);
|
||||
const uniqueName = user.username!;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
|
@ -189,11 +188,14 @@ test.describe("Teams - NonOrg", () => {
|
|||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Publish team").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("[data-testid=publish-button]").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can access user and team with same slug", async () => {
|
||||
|
@ -210,13 +212,11 @@ test.describe("Teams - NonOrg", () => {
|
|||
await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName);
|
||||
|
||||
// cleanup team
|
||||
const team = await prisma.team.findFirst({ where: { slug: uniqueName } });
|
||||
await prisma.team.delete({ where: { id: team?.id } });
|
||||
await prisma.team.deleteMany({ where: { slug: uniqueName } });
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
|
@ -224,11 +224,14 @@ test.describe("Teams - NonOrg", () => {
|
|||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(ownerObj, {
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
|
@ -278,45 +281,43 @@ test.describe("Teams - Org", () => {
|
|||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
|
||||
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.locator("text=Finish").click();
|
||||
await page.waitForURL("/settings/teams");
|
||||
await page.getByTestId("publish-button").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator('[data-testid="team-list-item-link"]').click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("text=Disband Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.getByTestId("disband-team-button").click();
|
||||
await page.getByTestId("dialog-confirmation").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
|
@ -361,13 +362,13 @@ test.describe("Teams - Org", () => {
|
|||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) {
|
||||
|
@ -380,18 +381,20 @@ test.describe("Teams - Org", () => {
|
|||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
const owner = await users.create(
|
||||
{ username: "pro-user", name: "pro-user" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
@ -402,17 +405,17 @@ test.describe("Teams - Org", () => {
|
|||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The person who booked the meeting should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal";
|
||||
|
@ -10,6 +10,7 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
|||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
@ -33,11 +34,21 @@ type FormValues = {
|
|||
const AddNewTeamMembers = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const session = useSession();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1;
|
||||
const teamQuery = trpc.viewer.teams.get.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: session.status === "authenticated" }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const event = searchParams?.get("event");
|
||||
if (event === "team_created") {
|
||||
telemetry.event(telemetryEventTypes.team_created);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (session.status === "loading" || !teamQuery.data) return <AddNewTeamMemberSkeleton />;
|
||||
|
||||
return <AddNewTeamMembersForm defaultValues={{ members: teamQuery.data.members }} teamId={teamId} />;
|
||||
|
@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({
|
|||
)}
|
||||
<hr className="border-subtle my-6" />
|
||||
<Button
|
||||
data-testid="publish-button"
|
||||
EndIcon={!orgBranding ? ArrowRight : undefined}
|
||||
color="primary"
|
||||
className="w-full justify-center"
|
||||
disabled={publishTeamMutation.isLoading}
|
||||
onClick={() => {
|
||||
if (orgBranding) {
|
||||
router.push("/settings/teams");
|
||||
} else {
|
||||
publishTeamMutation.mutate({ teamId });
|
||||
}
|
||||
router.push(`/settings/teams/${teamId}/profile`);
|
||||
}}>
|
||||
{t(orgBranding ? "finish" : "team_publish")}
|
||||
{t("finish")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -4,14 +4,15 @@ import { Controller, useForm } from "react-hook-form";
|
|||
import { z } from "zod";
|
||||
|
||||
import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Button, Form, ImageUploader, TextField, Alert, Label } from "@calcom/ui";
|
||||
import { ArrowRight, Plus } from "@calcom/ui/components/icon";
|
||||
import { Alert, Button, Form, TextField } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useOrgBranding } from "../../organizations/context/provider";
|
||||
import type { NewTeamFormValues } from "../lib/types";
|
||||
|
@ -21,8 +22,19 @@ const querySchema = z.object({
|
|||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
|
||||
const flag = isTeamBillingEnabledClient
|
||||
? {
|
||||
telemetryEvent: telemetryEventTypes.team_checkout_session_created,
|
||||
submitLabel: "checkout",
|
||||
}
|
||||
: {
|
||||
telemetryEvent: telemetryEventTypes.team_created,
|
||||
submitLabel: "continue",
|
||||
};
|
||||
|
||||
export const CreateANewTeamForm = () => {
|
||||
const { t } = useLocale();
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const params = useParamsWithFallback();
|
||||
|
@ -42,8 +54,8 @@ export const CreateANewTeamForm = () => {
|
|||
|
||||
const createTeamMutation = trpc.viewer.teams.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
telemetry.event(telemetryEventTypes.team_created);
|
||||
router.push(`/settings/teams/${data.id}/onboard-members`);
|
||||
telemetry.event(flag.telemetryEvent);
|
||||
router.push(data.url);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.message === "team_url_taken") {
|
||||
|
@ -81,6 +93,10 @@ export const CreateANewTeamForm = () => {
|
|||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<TextField
|
||||
disabled={
|
||||
/* E2e is too fast and it tries to fill this way before the form is ready */
|
||||
!isLocaleReady || createTeamMutation.isLoading
|
||||
}
|
||||
className="mt-2"
|
||||
placeholder="Acme Inc."
|
||||
name="name"
|
||||
|
@ -128,38 +144,6 @@ export const CreateANewTeamForm = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Controller
|
||||
control={newTeamFormMethods.control}
|
||||
name="logo"
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
<Label>{t("team_logo")}</Label>
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={value}
|
||||
fallback={<Plus className="text-subtle h-6 w-6" />}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("update")}
|
||||
handleAvatarChange={(newAvatar: string) => {
|
||||
newTeamFormMethods.setValue("logo", newAvatar);
|
||||
createTeamMutation.reset();
|
||||
}}
|
||||
imageSrc={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
disabled={createTeamMutation.isLoading}
|
||||
|
@ -174,7 +158,7 @@ export const CreateANewTeamForm = () => {
|
|||
EndIcon={ArrowRight}
|
||||
type="submit"
|
||||
className="w-full justify-center">
|
||||
{t("continue")}
|
||||
{t(flag.submitLabel)}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
|
|
@ -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 } }) => (
|
||||
<>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,6 +185,7 @@ export default function TeamListItem(props: Props) {
|
|||
)}
|
||||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
team.slug ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
|
@ -187,6 +193,9 @@ export default function TeamListItem(props: Props) {
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 })}
|
||||
|
|
|
@ -29,6 +29,51 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => {
|
|||
return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` };
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to generate a checkout session when trying to create a team
|
||||
*/
|
||||
export const generateTeamCheckoutSession = async ({
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
}: {
|
||||
teamName: string;
|
||||
teamSlug: string;
|
||||
userId: number;
|
||||
}) => {
|
||||
const customer = await getStripeCustomerIdFromUserId(userId);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
|
||||
line_items: [
|
||||
{
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
/**Initially it will be just the team owner */
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
},
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams
|
||||
*/
|
||||
export const purchaseTeamSubscription = async (input: {
|
||||
teamId: number;
|
||||
seats: number;
|
||||
|
|
|
@ -194,7 +194,11 @@ const ProfileView = () => {
|
|||
<Dialog>
|
||||
<SectionBottomActions align="end">
|
||||
<DialogTrigger asChild>
|
||||
<Button color="destructive" className="border" StartIcon={Trash2}>
|
||||
<Button
|
||||
color="destructive"
|
||||
className="border"
|
||||
StartIcon={Trash2}
|
||||
data-testid="disband-team-button">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
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 (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("inviteMember"),
|
||||
() => import("./inviteMember/inviteMember.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
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 (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("changeMemberRole"),
|
||||
() => import("./changeMemberRole.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
getMemberAvailability: authedProcedure.input(ZGetMemberAvailabilityInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getMemberAvailability"),
|
||||
() => import("./getMemberAvailability.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
getMembershipbyUser: authedProcedure.input(ZGetMembershipbyUserInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getMembershipbyUser"),
|
||||
() => import("./getMembershipbyUser.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
updateMembership: authedProcedure.input(ZUpdateMembershipInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("updateMembership"),
|
||||
() => import("./updateMembership.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
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,
|
||||
});
|
||||
publish: authedProcedure.input(ZPublishInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("publish"), () => import("./publish.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
|
||||
/** 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
|
||||
getUpgradeable: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getUpgradeable"),
|
||||
() => import("./getUpgradeable.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
listMembers: authedProcedure.input(ZListMembersInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
hasTeamPlan: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("hasTeamPlan"), () => import("./hasTeamPlan.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
listInvites: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("listInvites"), () => import("./listInvites.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
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 (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("setInviteExpiration"),
|
||||
() => import("./setInviteExpiration.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
hasEditPermissionForUser: authedProcedure.input(ZHasEditPermissionForUserSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("hasEditPermissionForUser"),
|
||||
() => import("./hasEditPermissionForUser.handler")
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
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
|
||||
resendInvitation: authedProcedure.input(ZResendInvitationInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("resendInvitation"),
|
||||
() => import("./resendInvitation.handler")
|
||||
);
|
||||
}
|
||||
|
||||
if (!UNSTABLE_HANDLER_CACHE.createInvite) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.createInvite({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
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,
|
||||
});
|
||||
}),
|
||||
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,
|
||||
});
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -48,3 +48,5 @@ export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions)
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default acceptOrLeaveHandler;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -59,3 +59,5 @@ async function getInviteLink(token = "", isOrg = false, orgMembers = 0) {
|
|||
if (isOrg || orgMembers > 0) return orgInviteLink;
|
||||
return teamInviteLink;
|
||||
}
|
||||
|
||||
export default createInviteHandler;
|
||||
|
|
|
@ -82,3 +82,5 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
|||
// Sync Services: Close.cm
|
||||
closeComDeleteTeam(deletedTeam);
|
||||
};
|
||||
|
||||
export default deleteHandler;
|
||||
|
|
|
@ -31,3 +31,5 @@ export const deleteInviteHandler = async ({ ctx, input }: DeleteInviteOptions) =
|
|||
|
||||
await prisma.verificationToken.delete({ where: { id: verificationToken.id } });
|
||||
};
|
||||
|
||||
export default deleteInviteHandler;
|
||||
|
|
|
@ -36,3 +36,5 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
|
|||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default getHandler;
|
||||
|
|
|
@ -54,3 +54,5 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai
|
|||
{ user: member.user }
|
||||
);
|
||||
};
|
||||
|
||||
export default getMemberAvailabilityHandler;
|
||||
|
|
|
@ -29,3 +29,5 @@ export const getMembershipbyUserHandler = async ({ ctx, input }: GetMembershipby
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default getMembershipbyUserHandler;
|
||||
|
|
|
@ -43,3 +43,5 @@ export const getUpgradeableHandler = async ({ ctx }: GetUpgradeableOptions) => {
|
|||
});
|
||||
return teams;
|
||||
};
|
||||
|
||||
export default getUpgradeableHandler;
|
||||
|
|
|
@ -17,3 +17,5 @@ export const hasEditPermissionForUser = async ({ ctx, input }: HasEditPermission
|
|||
input,
|
||||
});
|
||||
};
|
||||
|
||||
export default hasEditPermissionForUser;
|
||||
|
|
|
@ -23,3 +23,5 @@ export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
|
|||
});
|
||||
return { hasTeamPlan: !!hasTeamPlan };
|
||||
};
|
||||
|
||||
export default hasTeamPlanHandler;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
.refine(
|
||||
(value) => {
|
||||
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.`,
|
||||
});
|
||||
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;
|
||||
}),
|
||||
},
|
||||
{ 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),
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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++) {
|
||||
|
|
|
@ -64,3 +64,5 @@ export const inviteMemberByTokenHandler = async ({ ctx, input }: InviteMemberByT
|
|||
|
||||
return verificationToken.team.name;
|
||||
};
|
||||
|
||||
export default inviteMemberByTokenHandler;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -18,3 +18,5 @@ export const listInvitesHandler = async ({ ctx }: ListInvitesOptions) => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default listInvitesHandler;
|
||||
|
|
|
@ -54,3 +54,5 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersOptions) =>
|
|||
|
||||
return Object.values(users);
|
||||
};
|
||||
|
||||
export default listMembersHandler;
|
||||
|
|
|
@ -157,3 +157,5 @@ export const publishHandler = async ({ ctx, input }: PublishOptions) => {
|
|||
message: "Team published successfully",
|
||||
};
|
||||
};
|
||||
|
||||
export default publishHandler;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -58,3 +58,5 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption
|
|||
|
||||
return input;
|
||||
};
|
||||
|
||||
export default resendInvitationHandler;
|
||||
|
|
|
@ -39,3 +39,5 @@ export const setInviteExpirationHandler = async ({ ctx, input }: SetInviteExpira
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default setInviteExpirationHandler;
|
||||
|
|
|
@ -97,3 +97,5 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
darkBrandColor: updatedTeam.darkBrandColor,
|
||||
};
|
||||
};
|
||||
|
||||
export default updateHandler;
|
||||
|
|
|
@ -32,3 +32,5 @@ export const updateMembershipHandler = async ({ ctx, input }: UpdateMembershipOp
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default updateMembershipHandler;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -69,7 +69,11 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
|
|||
{confirmBtn ? (
|
||||
confirmBtn
|
||||
) : (
|
||||
<DialogClose color="primary" loading={isLoading} onClick={(e) => onConfirm && onConfirm(e)}>
|
||||
<DialogClose
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={(e) => onConfirm && onConfirm(e)}
|
||||
data-testid="dialog-confirmation">
|
||||
{isLoading ? loadingText : confirmBtnText}
|
||||
</DialogClose>
|
||||
)}
|
||||
|
|
Loading…
Reference in New Issue
Block a user