From 16c5b070b61343b1b83664eda3ab84abc83be1ff Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:46:03 +0400 Subject: [PATCH 001/292] fix: Admin Logic for event-type API endpoint (#12482) * Fix Admin logic * chore: fix prettier --------- Co-authored-by: Udit Takkar --- apps/api/pages/api/event-types/_post.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 1531485e7b..6eeb59f5c9 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -316,8 +316,13 @@ async function checkPermissions(req: NextApiRequest) { statusCode: 401, message: "ADMIN required for `userId`", }); + if (!isAdmin && body.teamId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `teamId`", + }); /* Admin users are required to pass in a userId or teamId */ - if (isAdmin && (!body.userId || !body.teamId)) + if (isAdmin && !body.userId && !body.teamId) throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" }); } From 4b060fc2cd12fde1670e7f2db8880c4eaf5c4af3 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Wed, 22 Nov 2023 15:19:37 +0530 Subject: [PATCH 002/292] fix: opening team invite link in email throws error on signup page (#12397) Co-authored-by: Keith Williams --- .../viewer/teams/inviteMember/inviteMember.handler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 79d560050a..5eaebe0844 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -119,6 +119,11 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = identifier: usernameOrEmail, token, expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: team.id, + }, + }, }, }); From 828092c1d0e773e9e882c64ead31d88e4b3b7b73 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:18:09 +0000 Subject: [PATCH 003/292] chore: fix cal.ai price (#12485) --- packages/app-store/cal-ai/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json index e6718b7b5d..6ec6551057 100644 --- a/packages/app-store/cal-ai/config.json +++ b/packages/app-store/cal-ai/config.json @@ -15,7 +15,7 @@ "__template": "basic", "dirName": "cal-ai", "paid": { - "priceInUsd": 25, + "priceInUsd": 8, "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP", "mode": "subscription" } From 113195224aff7a9bc9237b22fcacda31c95ed0a9 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:23:54 +0000 Subject: [PATCH 004/292] chore: fixed cal.ai thumbnail (#12486) --- packages/features/tips/Tips.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/tips/Tips.tsx b/packages/features/tips/Tips.tsx index e0e3a14fac..3ac41350c3 100644 --- a/packages/features/tips/Tips.tsx +++ b/packages/features/tips/Tips.tsx @@ -96,7 +96,7 @@ export const tips = [ { id: 12, thumbnailUrl: - "https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2", + "https://cal.com/og-image-cal-ai.jpg", mediaLink: "https://go.cal.com/cal-ai", title: "Cal.ai", description: "Your personal AI scheduling assistant", From af2c6c08441336a0f25ce68194482a8bab8462d0 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 22 Nov 2023 10:25:56 +0000 Subject: [PATCH 005/292] chore: ignore "platform" in pr-assign-team workflow (#12487) --- .github/workflows/pr-assign-team-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml index f6c02d1fd5..ecb601f75c 100644 --- a/.github/workflows/pr-assign-team-label.yml +++ b/.github/workflows/pr-assign-team-label.yml @@ -13,4 +13,4 @@ jobs: with: repo-token: ${{ secrets.GH_ACCESS_TOKEN }} organization-name: calcom - ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" + ignore-labels: "app-store, ai, authentication, automated-testing, platform, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier" From a3b5263b766fc66bc4e8af371dbbaa55ec047912 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 22 Nov 2023 16:12:19 +0530 Subject: [PATCH 006/292] chore: reset form on submission (#12465) --- .../features/eventtypes/components/CreateEventTypeDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx index 6fd6483d65..becaf6bcef 100644 --- a/packages/features/eventtypes/components/CreateEventTypeDialog.tsx +++ b/packages/features/eventtypes/components/CreateEventTypeDialog.tsx @@ -125,6 +125,7 @@ export default function CreateEventTypeDialog({ }), "success" ); + form.reset(); }, onError: (err) => { if (err instanceof HttpError) { From 73aa1e8a22c545ee857c4bb33240f618ddc3f4d4 Mon Sep 17 00:00:00 2001 From: Adugna Tadesse Date: Wed, 22 Nov 2023 14:01:29 +0300 Subject: [PATCH 007/292] outlook second account fix (#12013) Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- packages/app-store/office365calendar/api/add.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-store/office365calendar/api/add.ts b/packages/app-store/office365calendar/api/add.ts index 60e06d18b1..e087eab78a 100644 --- a/packages/app-store/office365calendar/api/add.ts +++ b/packages/app-store/office365calendar/api/add.ts @@ -20,6 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) response_type: "code", scope: scopes.join(" "), client_id, + prompt: "select_account", redirect_uri: `${WEBAPP_URL}/api/integrations/office365calendar/callback`, state, }; From cb7ddc455ad7334228cabb3f03552db22fb378f0 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 22 Nov 2023 16:56:43 +0530 Subject: [PATCH 008/292] chore: Add team invite tests (#12425) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/playwright/team/expects.ts | 29 ++++ .../playwright/team/team-invitation.e2e.ts | 124 ++++++++++++++++++ .../ee/teams/components/MemberListItem.tsx | 9 +- .../components/form/inputs/HintOrErrors.tsx | 9 +- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 apps/web/playwright/team/expects.ts create mode 100644 apps/web/playwright/team/team-invitation.e2e.ts diff --git a/apps/web/playwright/team/expects.ts b/apps/web/playwright/team/expects.ts new file mode 100644 index 0000000000..43e02063f6 --- /dev/null +++ b/apps/web/playwright/team/expects.ts @@ -0,0 +1,29 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; +import type { API, Messages } from "mailhog"; + +import { getEmailsReceivedByUser } from "../lib/testUtils"; + +export async function expectInvitationEmailToBeReceived( + page: Page, + emails: API | undefined, + userEmail: string, + subject: string, + returnLink?: string +) { + if (!emails) return null; + + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(10000); + const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); + expect(receivedEmails?.total).toBe(1); + + const [firstReceivedEmail] = (receivedEmails as Messages).items; + + expect(firstReceivedEmail.subject).toBe(subject); + if (!returnLink) return; + const dom = new JSDOM(firstReceivedEmail.html); + const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); + return anchor?.getAttribute("href"); +} diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts new file mode 100644 index 0000000000..95505bf279 --- /dev/null +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -0,0 +1,124 @@ +import { expect } from "@playwright/test"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { test } from "../lib/fixtures"; +import { localize } from "../lib/testUtils"; +import { expectInvitationEmailToBeReceived } from "./expects"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users, emails, clipboard }) => { + clipboard.reset(); + await users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("Team", () => { + test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { + const t = await localize("en"); + const teamOwner = await users.create(undefined, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the team by email (external user)", async () => { + const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + const inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${team.name}'s admin invited you to join the team ${team.name} on Cal.com`, + "signup?token" + ); + + //Check newly invited member exists and is pending + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!inviteLink) return null; + + // Follow invite link to new window + const context = await browser.newContext(); + const newPage = await context.newPage(); + await newPage.goto(inviteLink); + await newPage.waitForLoadState("networkidle"); + + // Check required fields + await newPage.locator("button[type=submit]").click(); + await expect(newPage.locator('[data-testid="hint-error"]')).toHaveCount(3); + await newPage.locator("input[name=password]").fill(`P4ssw0rd!`); + await newPage.locator("button[type=submit]").click(); + await newPage.waitForURL("/getting-started?from=signup"); + await newPage.close(); + await context.close(); + + // Check newly invited member is not pending anymore + await page.bringToFront(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(0); + }); + + await test.step("To the team by invite link", async () => { + const user = await users.create({ + email: `user-invite-${Date.now()}@domain.com`, + password: "P4ssw0rd!", + }); + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator(`[data-testid="copy-invite-link-button"]`).click(); + const inviteLink = await clipboard.get(); + await page.waitForLoadState("networkidle"); + + const context = await browser.newContext(); + const inviteLinkPage = await context.newPage(); + await inviteLinkPage.goto(inviteLink); + await inviteLinkPage.waitForLoadState("domcontentloaded"); + + await inviteLinkPage.locator("button[type=submit]").click(); + await expect(inviteLinkPage.locator('[data-testid="field-error"]')).toHaveCount(2); + + await inviteLinkPage.locator("input[name=email]").fill(user.email); + await inviteLinkPage.locator("input[name=password]").fill(user.username || "P4ssw0rd!"); + await inviteLinkPage.locator("button[type=submit]").click(); + + await inviteLinkPage.waitForURL(`${WEBAPP_URL}/teams**`); + }); + }); + + test("Invitation (verified)", async ({ browser, page, users, emails }) => { + const t = await localize("en"); + const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeam(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + await page.waitForLoadState("networkidle"); + + await test.step("To the organization by email (internal user)", async () => { + const invitedUserEmail = `rick@example.com`; + await page.locator(`button:text("${t("add")}")`).click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator(`button:text("${t("send_invite")}")`).click(); + await page.waitForLoadState("networkidle"); + await expectInvitationEmailToBeReceived( + page, + emails, + invitedUserEmail, + `${teamOwner.name} invited you to join the team ${team.name} on Cal.com` + ); + + await expect( + page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) + ).toHaveCount(1); + }); + }); +}); diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 2b356747ca..2f2bacfa32 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -152,7 +152,14 @@ export default function MemberListItem(props: Props) { {props.member.role && }
- + {props.member.email} {bookingLink && ( diff --git a/packages/ui/components/form/inputs/HintOrErrors.tsx b/packages/ui/components/form/inputs/HintOrErrors.tsx index a2115f7c56..adc3ce6fca 100644 --- a/packages/ui/components/form/inputs/HintOrErrors.tsx +++ b/packages/ui/components/form/inputs/HintOrErrors.tsx @@ -50,7 +50,10 @@ export function HintsOrErrors({ return (
  • + data-testid="hint-error" + className={ + error !== undefined ? (submitted ? "bg-yellow-200 text-red-700" : "") : "text-green-600" + }> {error !== undefined ? ( submitted ? ( @@ -72,7 +75,9 @@ export function HintsOrErrors({ // errors exist, not custom ones, just show them as is if (fieldErrors) { return ( -
    +
    From d04226ab9aef6ed58f5b39923e383e7fcfcb3e2d Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:39:00 +0200 Subject: [PATCH 009/292] fix: alby payment isPaid always false on create (#12463) --- packages/app-store/alby/lib/PaymentService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-store/alby/lib/PaymentService.ts b/packages/app-store/alby/lib/PaymentService.ts index 71e9c3e851..c29b08427a 100644 --- a/packages/app-store/alby/lib/PaymentService.ts +++ b/packages/app-store/alby/lib/PaymentService.ts @@ -74,7 +74,7 @@ export class PaymentService implements IAbstractPaymentService { currency: payment.currency, data: Object.assign( {}, - { invoice: { ...invoice, isPaid: await invoice.isPaid() } } + { invoice: { ...invoice, isPaid: false } } ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, From 2853288f497bd9710b732b6a8280c0c360eb8561 Mon Sep 17 00:00:00 2001 From: sebzz Date: Wed, 22 Nov 2023 17:19:27 +0530 Subject: [PATCH 010/292] docs: add google credentials in example env (#11695) * docs:add google credentials in example env * docs: add a space after # * chore: update .env.example --------- Co-authored-by: Udit Takkar --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 46237514b5..dfa0a49d66 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,19 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# Google OAuth credentials +# To enable Login with Google you need to: +# 1. Set `GOOGLE_API_CREDENTIALS` above +# 2. Set `GOOGLE_LOGIN_ENABLED` to `true` +# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance +# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications +GOOGLE_LOGIN_ENABLED=false + +# - GOOGLE CALENDAR/MEET/LOGIN +# Needed to enable Google Calendar integration and Login with Google +# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials +GOOGLE_API_CREDENTIALS= + # Inbox to send user feedback SEND_FEEDBACK_EMAIL= From 9a6683e01dace9cbe426ec35084e38201555085d Mon Sep 17 00:00:00 2001 From: Matt Nicolls <2540582+nicolls1@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:04:51 +0100 Subject: [PATCH 011/292] fix: include eventTypeId in BOOKING_CANCELLED event (#12445) --- packages/features/bookings/lib/handleCancelBooking.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 217972f873..7148be943f 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -227,6 +227,7 @@ async function handler(req: CustomRequest) { type: bookingToDelete?.eventType?.slug as string, description: bookingToDelete?.description || "", customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs), + eventTypeId: bookingToDelete.eventTypeId as number, ...getCalEventResponses({ bookingFields: bookingToDelete.eventType?.bookingFields ?? null, booking: bookingToDelete, From 2498785c49b44e13862f75346c245701792200f3 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 22 Nov 2023 08:22:03 -0500 Subject: [PATCH 012/292] chore: Clean Up Delete Credential Selected Calendar Error Message (#12353) --- .../deleteCredential.handler.ts | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts index fb716afb84..e396d5e0d4 100644 --- a/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/deleteCredential.handler.ts @@ -1,4 +1,3 @@ -import { Prisma } from "@prisma/client"; import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; @@ -328,52 +327,37 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp } } + // Backwards compatibility. Selected calendars cascade on delete when deleting a credential + // If it's a calendar remove it from the SelectedCalendars + if (credential.app?.categories.includes(AppCategories.calendar)) { + try { + const calendar = await getCalendar(credential); + + const calendars = await calendar?.listCalendars(); + + const calendarIds = calendars?.map((cal) => cal.externalId); + + await prisma.selectedCalendar.deleteMany({ + where: { + userId: user.id, + integration: credential.type as string, + externalId: { + in: calendarIds, + }, + }, + }); + } catch (error) { + console.warn( + `Error deleting selected calendars for userId: ${user.id} integration: ${credential.type}`, + error + ); + } + } + // Validated that credential is user's above await prisma.credential.delete({ where: { id: id, }, }); - - // Backwards compatibility. Selected calendars cascade on delete when deleting a credential - // If it's a calendar remove it from the SelectedCalendars - if (credential.app?.categories.includes(AppCategories.calendar)) { - const selectedCalendars = await prisma.selectedCalendar.findMany({ - where: { - userId: user.id, - integration: credential.type as string, - }, - }); - - if (selectedCalendars.length) { - const calendar = await getCalendar(credential); - - const calendars = await calendar?.listCalendars(); - - if (calendars && calendars.length > 0) { - calendars.map(async (cal) => { - prisma.selectedCalendar - .delete({ - where: { - userId_integration_externalId: { - userId: user.id, - externalId: cal.externalId, - integration: cal.integration as string, - }, - }, - }) - .catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") { - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId}. Could not find selected calendar.` - ); - } - console.log( - `Error deleting selected calendars for user ${user.id} and calendar ${credential.appId} with error: ${error}` - ); - }); - }); - } - } - } }; From b762f602144a429c9580b4e3325ce7730161efef Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 22 Nov 2023 09:15:47 -0500 Subject: [PATCH 013/292] test: Integration Test GCal Primary Calendar (#12011) Co-authored-by: Alex van Andel --- .env.example | 6 + apps/web/playwright/fixtures/users.ts | 12 +- .../lib/CalendarService.test.ts | 20 +- .../googlecalendar/lib/CalendarService.ts | 2 +- .../tests/google-calendar.e2e.ts | 215 ++++++++++++++++++ .../googlecalendar/tests/testUtils.ts | 127 +++++++++++ packages/prisma/seed.ts | 16 ++ turbo.json | 4 + 8 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 packages/app-store/googlecalendar/tests/google-calendar.e2e.ts create mode 100644 packages/app-store/googlecalendar/tests/testUtils.ts diff --git a/.env.example b/.env.example index dfa0a49d66..3690d058f9 100644 --- a/.env.example +++ b/.env.example @@ -250,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL= E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_PASSWORD="" +# - CALCOM QA ACCOUNT +# Used for E2E tests on Cal.com that require 3rd party integrations +E2E_TEST_CALCOM_QA_EMAIL="qa@example.com" +# Replace with your own password +E2E_TEST_CALCOM_QA_PASSWORD="password" + # - APP CREDENTIAL SYNC *********************************************************************************** # Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations # Under settings/admin/apps ensure that all app secrets are set the same as the parent application diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index ae5fbfbec2..b0d0a48c65 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -396,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn await prisma.user.delete({ where: { id } }); store.users = store.users.filter((b) => b.id !== id); }, + set: async (email: string) => { + const user = await prisma.user.findUniqueOrThrow({ + where: { email }, + include: userIncludes, + }); + const userFixture = createUserFixture(user, store.page); + store.users.push(userFixture); + return userFixture; + }, }; }; @@ -420,7 +429,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { eventTypes: user.eventTypes, routingForms: user.routingForms, self, - apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page), + apiLogin: async (password?: string) => + apiLogin({ ...(await self()), password: password || user.username }, store.page), /** * @deprecated use apiLogin instead */ diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index 8a416ea6eb..8cf8f5b247 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -78,17 +78,15 @@ test("Calendar Cache is being called", async () => { // prismaMock.calendarCache.create.mock. const calendarService = new CalendarService(testCredential); - // @ts-expect-error authedCalendar is a private method, hence the TS error - vi.spyOn(calendarService, "authedCalendar").mockReturnValue( - // @ts-expect-error trust me bro - { - freebusy: { - query: vi.fn().mockReturnValue({ - data: testFreeBusyResponse, - }), - }, - } - ); + vi.spyOn(calendarService, "authedCalendar").mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore - Mocking the authedCalendar so can't return the actual response + freebusy: { + query: vi.fn().mockReturnValue({ + data: testFreeBusyResponse, + }), + }, + }); await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ testSelectedCalendar, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index f3af3a9cff..e01982378b 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -132,7 +132,7 @@ export default class GoogleCalendarService implements Calendar { }; }; - private authedCalendar = async () => { + public authedCalendar = async () => { const myGoogleAuth = await this.auth.getToken(); const calendar = google.calendar({ version: "v3", diff --git a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts new file mode 100644 index 0000000000..226b7a61cd --- /dev/null +++ b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts @@ -0,0 +1,215 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import dayjs from "@calcom/dayjs"; +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { test } from "@calcom/web/playwright/lib/fixtures"; +import { selectSecondAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; +import { createBookingAndFetchGCalEvent, deleteBookingAndEvent, assertValueExists } from "./testUtils"; + +test.describe("Google Calendar", async () => { + test.describe("Test using the primary calendar", async () => { + let qaUsername: string; + let qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }>; + test.beforeAll(async () => { + let runIntegrationTest = false; + + test.skip(!!APP_CREDENTIAL_SHARING_ENABLED, "Credential sharing enabled"); + + if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { + qaGCalCredential = await prisma.credential.findFirstOrThrow({ + where: { + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + type: metadata.type, + }, + select: { + id: true, + }, + }); + + const qaUserQuery = await prisma.user.findFirstOrThrow({ + where: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + select: { + username: true, + }, + }); + + assertValueExists(qaUserQuery.username, "qaUsername"); + qaUsername = qaUserQuery.username; + + if (qaGCalCredential && qaUsername) runIntegrationTest = true; + } + + test.skip(!runIntegrationTest, "QA user not found"); + }); + + test.beforeEach(async ({ page, users }) => { + assertValueExists(process.env.E2E_TEST_CALCOM_QA_EMAIL, "qaEmail"); + + const qaUserStore = await users.set(process.env.E2E_TEST_CALCOM_QA_EMAIL); + + await qaUserStore.apiLogin(process.env.E2E_TEST_CALCOM_QA_PASSWORD); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + assertValueExists(refreshedCredential, "refreshedCredential"); + + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const calendars = await googleCalendarService.listCalendars(); + + const primaryCalendarName = calendars.find((calendar) => calendar.primary)?.name; + assertValueExists(primaryCalendarName, "primaryCalendarName"); + + await page.goto("/apps/installed/calendar"); + + await page.waitForSelector('[title*="Create events on"]'); + await page.locator('[title*="Create events on"]').locator("svg").click(); + await page.locator("#react-select-2-option-0-0").getByText(primaryCalendarName).click(); + }); + + test("On new booking, event should be created on GCal", async ({ page }) => { + const { gCalEvent, gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page as Page, + qaGCalCredential, + qaUsername + ); + + assertValueExists(gCalEvent.start?.timeZone, "gCalEvent"); + assertValueExists(gCalEvent.end?.timeZone, "gCalEvent"); + + // Ensure that the start and end times are matching + const startTimeMatches = dayjs(booking.startTime).isSame( + dayjs(gCalEvent.start.dateTime).tz(gCalEvent.start.timeZone) + ); + const endTimeMatches = dayjs(booking.endTime).isSame( + dayjs(gCalEvent.end?.dateTime).tz(gCalEvent.end.timeZone) + ); + expect(startTimeMatches && endTimeMatches).toBe(true); + + // Ensure that the titles are matching + expect(booking.title).toBe(gCalEvent.summary); + + // Ensure that the attendee is on the event + const bookingAttendee = booking?.attendees[0].email; + const attendeeInGCalEvent = gCalEvent.attendees?.find((attendee) => attendee.email === bookingAttendee); + expect(attendeeInGCalEvent).toBeTruthy(); + + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + }); + + test("On reschedule, event should be updated on GCal", async ({ page }) => { + // Reschedule the booking and check the gCalEvent's time is also changed + // On reschedule gCal UID stays the same + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + await page.locator('[data-testid="reschedule-link"]').click(); + + await selectSecondAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + const rescheduledBookingUrl = await page.url(); + const rescheduledBookingUid = rescheduledBookingUrl.match(/booking\/([^\/?]+)/); + + assertValueExists(rescheduledBookingUid, "rescheduledBookingUid"); + + // Get the rescheduled booking start and end times + const rescheduledBooking = await prisma.booking.findFirst({ + where: { + uid: rescheduledBookingUid[1], + }, + select: { + startTime: true, + endTime: true, + }, + }); + assertValueExists(rescheduledBooking, "rescheduledBooking"); + + // The GCal event UID persists after reschedule but should get the rescheduled data + const gCalRescheduledEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalRescheduledEventResponse.status).toBe(200); + + const rescheduledGCalEvent = gCalRescheduledEventResponse.data; + + assertValueExists(rescheduledGCalEvent.start?.timeZone, "rescheduledGCalEvent"); + assertValueExists(rescheduledGCalEvent.end?.timeZone, "rescheduledGCalEvent"); + + // Ensure that the new start and end times are matching + const rescheduledStartTimeMatches = dayjs(rescheduledBooking.startTime).isSame( + dayjs(rescheduledGCalEvent.start?.dateTime).tz(rescheduledGCalEvent.start?.timeZone) + ); + const rescheduledEndTimeMatches = dayjs(rescheduledBooking.endTime).isSame( + dayjs(rescheduledGCalEvent.end?.dateTime).tz(rescheduledGCalEvent.end.timeZone) + ); + expect(rescheduledStartTimeMatches && rescheduledEndTimeMatches).toBe(true); + + // After test passes we can delete the bookings and GCal event + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + + await prisma.booking.delete({ + where: { + uid: rescheduledBookingUid[1], + }, + }); + }); + + test("When canceling the booking, the GCal event should also be deleted", async ({ page }) => { + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + // Cancel the booking + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="confirm_cancel"]').click(); + // Query for the bookingUID and ensure that it doesn't exist on GCal + + await page.waitForSelector('[data-testid="cancelled-headline"]'); + + const canceledGCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(canceledGCalEventResponse.data.status).toBe("cancelled"); + + // GCal API sees canceled events as already deleted + await deleteBookingAndEvent(authedCalendar, booking.uid); + }); + }); +}); diff --git a/packages/app-store/googlecalendar/tests/testUtils.ts b/packages/app-store/googlecalendar/tests/testUtils.ts new file mode 100644 index 0000000000..5d4920d2b1 --- /dev/null +++ b/packages/app-store/googlecalendar/tests/testUtils.ts @@ -0,0 +1,127 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { bookFirstEvent } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; + +/** + * Creates the booking on Cal.com and makes the GCal call to fetch the event. + * Ends on the booking success page + * @param page + * + * @returns the raw GCal event GET response and the booking reference + */ +export const createBookingAndFetchGCalEvent = async ( + page: Page, + qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }> | null, + qaUsername: string +) => { + await page.goto(`/${qaUsername}`); + await bookFirstEvent(page); + + const bookingUrl = await page.url(); + const bookingUid = bookingUrl.match(/booking\/([^\/?]+)/); + assertValueExists(bookingUid, "bookingUid"); + + const [gCalReference, booking] = await Promise.all([ + prisma.bookingReference.findFirst({ + where: { + booking: { + uid: bookingUid[1], + }, + type: metadata.type, + credentialId: qaGCalCredential?.id, + }, + select: { + uid: true, + booking: {}, + }, + }), + prisma.booking.findFirst({ + where: { + uid: bookingUid[1], + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + attendees: { + select: { + email: true, + }, + }, + user: { + select: { + email: true, + }, + }, + }, + }), + ]); + assertValueExists(gCalReference, "gCalReference"); + assertValueExists(booking, "booking"); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + expect(refreshedCredential).toBeTruthy(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const authedCalendar = await googleCalendarService.authedCalendar(); + + const gCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalEventResponse.status).toBe(200); + + return { gCalEvent: gCalEventResponse.data, gCalReference, booking, authedCalendar }; +}; + +export const deleteBookingAndEvent = async ( + authedCalendar: any, + bookingUid: string, + gCalReferenceUid?: string +) => { + // After test passes we can delete the booking and GCal event + await prisma.booking.delete({ + where: { + uid: bookingUid, + }, + }); + + if (gCalReferenceUid) { + await authedCalendar.events.delete({ + calendarId: "primary", + eventId: gCalReferenceUid, + }); + } +}; + +export function assertValueExists(value: unknown, variableName?: string): asserts value { + if (!value) { + throw new Error(`Value is not defined: ${variableName}`); + } +} diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 29d981a3a7..78c6861372 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -455,6 +455,22 @@ async function main() { }, }); + await createUserAndEventType({ + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", + password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", + username: "qa", + name: "QA Example", + }, + eventTypes: [ + { + title: "15min", + slug: "15min", + length: 15, + }, + ], + }); + await createTeamAndAddUsers( { name: "Seeded Team", diff --git a/turbo.json b/turbo.json index 1156aa13e5..0d7226a8ec 100644 --- a/turbo.json +++ b/turbo.json @@ -209,6 +209,8 @@ "CALCOM_CREDENTIAL_SYNC_ENDPOINT", "CALCOM_ENV", "CALCOM_LICENSE_KEY", + "CALCOM_QA_EMAIL", + "CALCOM_QA_PASSWORD", "CALCOM_TELEMETRY_DISABLED", "CALCOM_WEBHOOK_HEADER_NAME", "CALENDSO_ENCRYPTION_KEY", @@ -222,6 +224,8 @@ "DEBUG", "E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_PASSWORD", + "E2E_TEST_CALCOM_QA_EMAIL", + "E2E_TEST_CALCOM_QA_PASSWORD", "E2E_TEST_MAILHOG_ENABLED", "E2E_TEST_OIDC_CLIENT_ID", "E2E_TEST_OIDC_CLIENT_SECRET", From f65c7e413f4ffcad1039304a53b72998d6cb51a0 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 22 Nov 2023 23:13:25 +0530 Subject: [PATCH 014/292] fix: default organizer bug in managed event type (#11921) --- .../web/components/eventtype/EventTeamTab.tsx | 2 ++ .../playwright/fixtures/regularBookings.ts | 2 +- .../web/playwright/integrations-stripe.e2e.ts | 2 +- .../web/playwright/managed-event-types.e2e.ts | 32 +++++++++++++++++-- apps/web/playwright/payment-apps.e2e.ts | 6 ++-- .../components/EventTypeAppCardInterface.tsx | 2 ++ .../components/EventTypeAppCardInterface.tsx | 3 +- .../features/bookings/lib/handleNewBooking.ts | 5 ++- .../components/ChildrenEventTypeSelect.tsx | 1 + .../features/form-builder/FormBuilder.tsx | 1 + packages/ui/components/form/select/Select.tsx | 2 +- .../ui/components/form/select/components.tsx | 20 +++++++++++- .../ui/components/form/select/selectTheme.ts | 3 +- 13 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index 49917235a3..014fde0ed1 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -76,6 +76,8 @@ const ChildrenEventTypesList = ({
    { onChange && onChange( diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index 72c8e44fea..b0a84078e0 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -204,7 +204,7 @@ export function createBookingPageFixture(page: Page) { placeholder?: string ) => { await page.getByTestId("add-field").click(); - await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("test-field-type").click(); await page.getByTestId(`select-option-${questionType}`).click(); await page.getByLabel("Identifier").dblclick(); await page.getByLabel("Identifier").fill(identifier); diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index 25a1a33fa6..c9d86ccf0e 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -267,7 +267,7 @@ test.describe("Stripe integration", () => { await page.getByTestId("price-input-stripe").fill("200"); // Select currency in dropdown - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("stripe-currency-select").click(); await page.locator("#react-select-2-input").fill("mexi"); await page.locator("#react-select-2-option-81").click(); diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 52e6bf86c6..a0323ed8b7 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,6 +1,9 @@ import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { test } from "./lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils"; +import { localize } from "./lib/testUtils"; test.afterEach(({ users }) => users.deleteAll()); @@ -69,15 +72,34 @@ test.describe("Managed Event Types tests", () => { await page.goto("/event-types"); await page.getByTestId("event-types").locator('a[title="managed"]').click(); await page.getByTestId("vertical-tab-assignment").click(); - await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click(); + await page.getByTestId("assignment-dropdown").click(); + await page.getByTestId(`select-option-${memberUser.id}`).click(); await page.locator('[type="submit"]').click(); await page.getByTestId("toast-success").waitFor(); + }); - await adminUser.logout(); + await test.step("Managed event type can use Organizer's default app as location", async () => { + await page.getByTestId("vertical-tab-event_setup_tab_title").click(); + + await page.locator("#location-select").click(); + const optionText = (await localize("en"))("organizer_default_conferencing_app"); + await page.locator(`text=${optionText}`).click(); + await page.locator("[data-testid=update-eventtype]").click(); + await page.getByTestId("toast-success").waitFor(); + await page.waitForLoadState("networkidle"); + + await page.getByTestId("vertical-tab-assignment").click(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + + await expect(page.getByTestId("success-page")).toBeVisible(); }); await test.step("Managed event type has locked fields for added member", async () => { + await adminUser.logout(); + // Coming back as member user to see if there is a managed event present after assignment await memberUser.apiLogin(); await page.goto("/event-types"); @@ -91,3 +113,9 @@ test.describe("Managed Event Types tests", () => { }); }); }); + +async function gotoBookingPage(page: Page) { + const previewLink = await page.getByTestId("preview-button").getAttribute("href"); + + await page.goto(previewLink ?? ""); +} diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts index c01bc10ba2..77bf674d92 100644 --- a/apps/web/playwright/payment-apps.e2e.ts +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -77,7 +77,7 @@ test.describe("Payment app", () => { await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("stripe-currency-select").click(); await page.getByTestId("select-option-usd").click(); await page.getByTestId("price-input-stripe").click(); @@ -123,10 +123,10 @@ test.describe("Payment app", () => { await page.getByPlaceholder("Price").click(); await page.getByPlaceholder("Price").fill("150"); - await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-currency-select").click(); await page.locator("#react-select-2-option-13").click(); - await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId("paypal-payment-option-select").click(); await page.getByText("$MXNCurrencyMexican pesoPayment option").click(); await page.getByTestId("update-eventtype").click(); diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 536d159652..db6ba04755 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -92,6 +92,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ + data-testid="stripe-payment-option-select" defaultValue={ paymentOptionSelectValue ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 2d73a22145..a21848ce2e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1001,8 +1001,11 @@ async function handler( const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone; const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common"); + + const isManagedEventType = !!eventType.parentId; + // use host default - if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) { + if ((isManagedEventType || isTeamEventType) && locationBodyString === OrganizerDefaultConferencingAppType) { const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata); const organizerMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; if (organizerMetadata?.defaultConferencingApp?.appSlug) { diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index 328644da30..8a6218d197 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -106,6 +106,7 @@ export const ChildrenEventTypeSelect = ({ {children.created && children.owner.username && (
    diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts index 7ee9f4bbb9..b4a0c0f46f 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -10,7 +10,7 @@ type TestTriggerOptions = { }; export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { - const { url, type, payloadTemplate = null } = input; + const { url, type, payloadTemplate = null, secret = null } = input; const translation = await getTranslation("en", "common"); const language = { locale: "en", @@ -40,8 +40,8 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption }; try { - const webhook = { subscriberUrl: url, payloadTemplate, appId: null, secret: null }; - return await sendPayload(null, type, new Date().toISOString(), webhook, data); + const webhook = { subscriberUrl: url, appId: null, payloadTemplate }; + return await sendPayload(secret, type, new Date().toISOString(), webhook, data); } catch (_err) { const error = getErrorFromUnknown(_err); return { diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts index 53f92f7e88..faeef8ed25 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.schema.ts @@ -4,6 +4,7 @@ import { webhookIdAndEventTypeIdSchema } from "./types"; export const ZTestTriggerInputSchema = webhookIdAndEventTypeIdSchema.extend({ url: z.string().url(), + secret: z.string().optional(), type: z.string(), payloadTemplate: z.string().optional().nullable(), }); From 2171a320f50b15e68c44348708c7a28e175ff411 Mon Sep 17 00:00:00 2001 From: zomars Date: Wed, 22 Nov 2023 12:19:16 -0700 Subject: [PATCH 018/292] fix: Locks Stripe version --- apps/web/package.json | 2 +- packages/app-store/package.json | 2 +- packages/app-store/stripepayment/package.json | 2 +- yarn.lock | 27 +++++++------------ 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 8d4fceeb46..0056cc4d2f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -125,7 +125,7 @@ "sanitize-html": "^2.10.0", "schema-dts": "^1.1.0", "short-uuid": "^4.2.0", - "stripe": "^14.3.0", + "stripe": "^9.16.0", "superjson": "1.9.1", "tailwindcss-radix": "^2.6.0", "turndown": "^7.1.1", diff --git a/packages/app-store/package.json b/packages/app-store/package.json index 6cfd20e06a..62225f2b65 100644 --- a/packages/app-store/package.json +++ b/packages/app-store/package.json @@ -26,7 +26,7 @@ "lodash": "^4.17.21", "qs-stringify": "^1.2.1", "react-i18next": "^12.2.0", - "stripe": "^14.3.0" + "stripe": "^9.16.0" }, "devDependencies": { "@calcom/types": "*" diff --git a/packages/app-store/stripepayment/package.json b/packages/app-store/stripepayment/package.json index dcf922cb2a..95c3e878e3 100644 --- a/packages/app-store/stripepayment/package.json +++ b/packages/app-store/stripepayment/package.json @@ -19,7 +19,7 @@ "@calcom/types": "*", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", - "stripe": "^14.3.0", + "stripe": "^9.16.0", "uuid": "^8.3.2", "zod": "^3.22.2" }, diff --git a/yarn.lock b/yarn.lock index d1f6feba02..1bdac79184 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3484,7 +3484,7 @@ __metadata: lodash: ^4.17.21 qs-stringify: ^1.2.1 react-i18next: ^12.2.0 - stripe: ^14.3.0 + stripe: ^9.16.0 languageName: unknown linkType: soft @@ -4335,7 +4335,7 @@ __metadata: "@calcom/types": "*" "@stripe/react-stripe-js": ^1.10.0 "@stripe/stripe-js": ^1.35.0 - stripe: ^14.3.0 + stripe: ^9.16.0 ts-node: ^10.9.1 uuid: ^8.3.2 zod: ^3.22.2 @@ -4633,7 +4633,7 @@ __metadata: sanitize-html: ^2.10.0 schema-dts: ^1.1.0 short-uuid: ^4.2.0 - stripe: ^14.3.0 + stripe: ^9.16.0 superjson: 1.9.1 tailwindcss: ^3.3.3 tailwindcss-animate: ^1.0.6 @@ -4755,7 +4755,7 @@ __metadata: remark: ^14.0.2 remark-html: ^14.0.1 remeda: ^1.24.1 - stripe: ^14.3.0 + stripe: ^9.16.0 tailwind-merge: ^1.13.2 tailwindcss: ^3.3.3 ts-node: ^10.9.1 @@ -33265,15 +33265,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0": - version: 6.11.2 - resolution: "qs@npm:6.11.2" - dependencies: - side-channel: ^1.0.4 - checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b - languageName: node - linkType: hard - "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -37325,13 +37316,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^14.3.0": - version: 14.3.0 - resolution: "stripe@npm:14.3.0" +"stripe@npm:^9.16.0": + version: 9.16.0 + resolution: "stripe@npm:9.16.0" dependencies: "@types/node": ">=8.1.0" - qs: ^6.11.0 - checksum: 1aa0dec1fe8cd4c0d2a5378b9d3c69f7df505efdc86b8d6352e194d656129db83b9faaf189b5138fb5fd9a0b90e618dfcff854bb4773d289a0de0b65d0a94cb2 + qs: ^6.10.3 + checksum: d84eb9ef3fa0c50e1b62271bf822d3e9da22272ec7364ae8334db7277e42f657c42c10f6fa535c634c36081e17d1c8c5a1efc509b3747f84bfbe4cf2a94ade4b languageName: node linkType: hard From 36d315343c9a4654c4835234095104b2c875329e Mon Sep 17 00:00:00 2001 From: Manpreet Singh Date: Wed, 22 Nov 2023 11:54:18 -0800 Subject: [PATCH 019/292] fix: adds teamId to team events payload (#12417) --- packages/core/builders/CalendarEvent/class.ts | 2 +- .../bookings/lib/handleCancelBooking.ts | 20 ++++++++++++------- .../features/bookings/lib/handleNewBooking.ts | 2 +- packages/types/Calendar.d.ts | 1 + 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index 2b33d7223d..8a449dbfd2 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -17,7 +17,7 @@ class CalendarEventClass implements CalendarEvent { organizer!: Person; attendees!: Person[]; description?: string | null; - team?: { name: string; members: Person[] }; + team?: { name: string; members: Person[]; id: number }; location?: string | null; conferenceData?: ConferenceData; additionalInformation?: AdditionalInformation; diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 7148be943f..b71c9aa06d 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -72,7 +72,12 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine hideBranding: true, }, }, - teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, recurringEvent: true, title: true, eventName: true, @@ -151,11 +156,10 @@ async function handler(req: CustomRequest) { const teamId = await getTeamIdFromEventType({ eventType: { - team: { id: bookingToDelete.eventType?.teamId ?? null }, + team: { id: bookingToDelete.eventType?.team?.id ?? null }, parentId: bookingToDelete?.eventType?.parentId ?? null, }, }); - const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId); const subscriberOptions = { @@ -255,7 +259,9 @@ async function handler(req: CustomRequest) { ? [bookingToDelete?.user.destinationCalendar] : [], cancellationReason: cancellationReason, - ...(teamMembers && { team: { name: "", members: teamMembers } }), + ...(teamMembers && { + team: { name: bookingToDelete?.eventType?.team?.name || "Nameless", members: teamMembers, id: teamId! }, + }), seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees, }; @@ -408,7 +414,7 @@ async function handler(req: CustomRequest) { if (bookingToDelete.location === DailyLocationType) { bookingToDelete.user.credentials.push({ ...FAKE_DAILY_CREDENTIAL, - teamId: bookingToDelete.eventType?.teamId || null, + teamId: bookingToDelete.eventType?.team?.id || null, }); } @@ -540,10 +546,10 @@ async function handler(req: CustomRequest) { let eventTypeOwnerId; if (bookingToDelete.eventType?.owner) { eventTypeOwnerId = bookingToDelete.eventType.owner.id; - } else if (bookingToDelete.eventType?.teamId) { + } else if (bookingToDelete.eventType?.team?.id) { const teamOwner = await prisma.membership.findFirst({ where: { - teamId: bookingToDelete.eventType.teamId, + teamId: bookingToDelete.eventType?.team.id, role: MembershipRole.OWNER, }, select: { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index a21848ce2e..dd00afc82c 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1079,7 +1079,6 @@ async function handler( }, }; }); - const teamMembers = await Promise.all(teamMemberPromises); const attendeesList = [...invitee, ...guests]; @@ -1887,6 +1886,7 @@ async function handler( evt.team = { members: teamMembers, name: eventType.team?.name || "Nameless", + id: eventType.team?.id ?? 0, }; } diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index b8e3989f71..833855250c 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -160,6 +160,7 @@ export interface CalendarEvent { team?: { name: string; members: TeamMember[]; + id: number; }; location?: string | null; conferenceCredentialId?: number; From 5dc3065a470d86379eb32831a662f267a3fc717b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Wed, 22 Nov 2023 15:42:24 -0700 Subject: [PATCH 020/292] v3.5.1 --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 0056cc4d2f..aab6e07a2e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.5.0", + "version": "3.5.1", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From de479bb2da21e55247290313d95116d65a871742 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 23 Nov 2023 11:11:20 +0530 Subject: [PATCH 021/292] fix: getting-started crash and build failure (#12506) --- apps/web/lib/metadata.ts | 1 - apps/web/playwright/teams.e2e.ts | 2 +- packages/app-store/_utils/paid-apps.ts | 2 ++ .../app-store/routing-forms/playwright/tests/basic.e2e.ts | 2 +- packages/lib/hooks/useCompatSearchParams.tsx | 4 +++- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/metadata.ts b/apps/web/lib/metadata.ts index db37af3443..a1adf8d165 100644 --- a/apps/web/lib/metadata.ts +++ b/apps/web/lib/metadata.ts @@ -28,7 +28,6 @@ export const prepareRootMetadata = (recipe: RootMetadataRecipe): Metadata => ({ { rel: "icon-mask", url: "/safari-pinned-tab.svg", - // @ts-expect-error TODO available in the never Next.js version color: "#000000", }, { diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 0914cc4eb8..ae0c11d821 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -351,7 +351,7 @@ test.describe("Teams - Org", () => { await page.goto(`/team/${team.slug}/${teamEventSlug}`); - await expect(page.locator('[data-testid="404-page"]')).toBeVisible(); + await expect(page.locator("text=This page could not be found")).toBeVisible(); await doOnOrgDomain( { orgSlug: org.slug, diff --git a/packages/app-store/_utils/paid-apps.ts b/packages/app-store/_utils/paid-apps.ts index 6d39c98ff6..1217831a45 100644 --- a/packages/app-store/_utils/paid-apps.ts +++ b/packages/app-store/_utils/paid-apps.ts @@ -40,6 +40,8 @@ export const withPaidAppRedirect = async ({ ? { subscription_data: { trial_period_days: trialDays, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - trial_settings isn't available cc @erik trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, }, } diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index 7c9f2f9868..f09d5f8345 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -36,7 +36,7 @@ test.describe("Routing Forms", () => { await page.goto(`apps/routing-forms/route-builder/${formId}`); await disableForm(page); await gotoRoutingLink({ page, formId }); - await expect(page.locator("text=ERROR 404")).toBeVisible(); + await expect(page.locator("text=This page could not be found")).toBeVisible(); }); test("should be able to edit the form", async ({ page }) => { diff --git a/packages/lib/hooks/useCompatSearchParams.tsx b/packages/lib/hooks/useCompatSearchParams.tsx index 032ba115a1..3112bb3827 100644 --- a/packages/lib/hooks/useCompatSearchParams.tsx +++ b/packages/lib/hooks/useCompatSearchParams.tsx @@ -8,7 +8,9 @@ export const useCompatSearchParams = () => { Object.getOwnPropertyNames(params).forEach((key) => { searchParams.delete(key); - const param = params[key]; + // Though useParams is supposed to return a string/string[] as the key's value but it is found to return undefined as well. + // Maybe it happens for pages dir when using optional catch-all routes. + const param = params[key] || ""; const paramArr = typeof param === "string" ? param.split("/") : param; paramArr.forEach((p) => { From 343f8ee3031a628db38ff44fa16c8f3a76dfda02 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 23 Nov 2023 15:39:50 +0000 Subject: [PATCH 022/292] Avatar write and unset, ensure no bad behaviour (#12504) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../loggedInViewer/updateProfile.handler.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index c790a446ad..60855221eb 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -61,6 +61,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) const userMetadata = handleUserMetadata({ ctx, input }); const data: Prisma.UserUpdateInput = { ...input, + // DO NOT OVERWRITE AVATAR. + avatar: undefined, metadata: userMetadata, }; @@ -138,14 +140,21 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) // when the email changes, the user needs to sign in again. signOutUser = true; } - // don't do anything if avatar is undefined. - if (typeof input.avatar !== "undefined") { - data.avatarUrl = input.avatar - ? await uploadAvatar({ - avatar: await resizeBase64Image(input.avatar), - userId: user.id, - }) - : null; + // if defined AND a base 64 string, upload and set the avatar URL + if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) { + const avatar = await resizeBase64Image(input.avatar); + data.avatarUrl = await uploadAvatar({ + avatar, + userId: user.id, + }); + // as this is still used in the backwards compatible endpoint, we also write it here + // to ensure no data loss. + data.avatar = avatar; + } + // Unset avatar url if avatar is empty string. + if ("" === input.avatar) { + data.avatarUrl = null; + data.avatar = null; } const updatedUser = await prisma.user.update({ From 0910f65b8729b4d3a5daa73fe4a8c514101823f4 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 23 Nov 2023 16:56:52 +0000 Subject: [PATCH 023/292] v3.5.2 --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index aab6e07a2e..245685bc06 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.5.1", + "version": "3.5.2", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From 9903fcaa05b978cab6102e38947f394b45aaa62a Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Nov 2023 18:07:08 -0300 Subject: [PATCH 024/292] chore: Remove free use of Cal.AI (#12489) --- packages/app-store/cal-ai/DESCRIPTION.md | 2 - packages/app-store/cal-ai/api/_getAdd.ts | 43 ----- yarn.lock | 210 +++++++++++++++++++++-- 3 files changed, 196 insertions(+), 59 deletions(-) diff --git a/packages/app-store/cal-ai/DESCRIPTION.md b/packages/app-store/cal-ai/DESCRIPTION.md index 9643d5ee75..6c045ee7c0 100644 --- a/packages/app-store/cal-ai/DESCRIPTION.md +++ b/packages/app-store/cal-ai/DESCRIPTION.md @@ -7,8 +7,6 @@ items: - 5.jpg --- -**FREE TRIAL until December 1st, 2023** - {DESCRIPTION} ## Example questions: diff --git a/packages/app-store/cal-ai/api/_getAdd.ts b/packages/app-store/cal-ai/api/_getAdd.ts index a607a83e7b..d8e13972c6 100644 --- a/packages/app-store/cal-ai/api/_getAdd.ts +++ b/packages/app-store/cal-ai/api/_getAdd.ts @@ -1,57 +1,14 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { defaultResponder } from "@calcom/lib/server"; -import { createContext } from "@calcom/trpc/server/createContext"; -import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router"; import checkSession from "../../_utils/auth"; -import getInstalledAppPath from "../../_utils/getInstalledAppPath"; -import { checkInstalled, createDefaultInstallation } from "../../_utils/installation"; import { withPaidAppRedirect } from "../../_utils/paid-apps"; import appConfig from "../config.json"; -const trialEndDate = new Date(Date.UTC(2023, 11, 1)); - export async function getHandler(req: NextApiRequest, res: NextApiResponse) { const session = checkSession(req); - // if date is in the future, we install normally. - if (new Date() < trialEndDate) { - const ctx = await createContext({ req, res }); - const caller = apiKeysRouter.createCaller(ctx); - - const apiKey = await caller.create({ - note: "Cal.ai", - expiresAt: null, - appId: "cal-ai", - }); - - await checkInstalled(appConfig.slug, session.user.id); - await createDefaultInstallation({ - appType: appConfig.type, - userId: session.user.id, - slug: appConfig.slug, - key: { - apiKey, - }, - }); - - await fetch( - `${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - userId: session.user.id, - }), - } - ); - - return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) }; - } - const redirectUrl = await withPaidAppRedirect({ appPaidMode: appConfig.paid.mode, appSlug: appConfig.slug, diff --git a/yarn.lock b/yarn.lock index 1bdac79184..79b331aa18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3190,6 +3190,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.17.2, @babel/runtime@npm:^7.18.6": + version: 7.23.4 + resolution: "@babel/runtime@npm:7.23.4" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 8eb6a6b2367f7d60e7f7dd83f477cc2e2fdb169e5460694d7614ce5c730e83324bcf29251b70940068e757ad1ee56ff8073a372260d90cad55f18a825caf97cd + languageName: node + linkType: hard + "@babel/runtime@npm:^7.21.0": version: 7.23.1 resolution: "@babel/runtime@npm:7.23.1" @@ -3543,15 +3552,13 @@ __metadata: "@calcom/ui": "*" "@types/node": 16.9.1 "@types/react": 18.0.26 - "@types/react-dom": ^18.0.9 + "@types/react-dom": 18.0.9 eslint: ^8.34.0 eslint-config-next: ^13.2.1 - next: ^13.4.6 - next-auth: ^4.22.1 - postcss: ^8.4.18 + next: ^13.2.1 + next-auth: ^4.20.1 react: ^18.2.0 react-dom: ^18.2.0 - tailwindcss: ^3.3.3 typescript: ^4.9.4 languageName: unknown linkType: soft @@ -3645,7 +3652,7 @@ __metadata: "@calcom/ui": "*" "@headlessui/react": ^1.5.0 "@heroicons/react": ^1.0.6 - "@prisma/client": ^5.4.2 + "@prisma/client": ^4.13.0 "@tailwindcss/forms": ^0.5.2 "@types/node": 16.9.1 "@types/react": 18.0.26 @@ -3653,21 +3660,21 @@ __metadata: chart.js: ^3.7.1 client-only: ^0.0.1 eslint: ^8.34.0 - next: ^13.4.6 - next-auth: ^4.22.1 - next-i18next: ^13.2.2 + next: ^13.2.1 + next-auth: ^4.20.1 + next-i18next: ^11.3.0 postcss: ^8.4.18 - prisma: ^5.4.2 + prisma: ^4.13.0 prisma-field-encryption: ^1.4.0 react: ^18.2.0 react-chartjs-2: ^4.0.1 react-dom: ^18.2.0 react-hook-form: ^7.43.3 - react-live-chat-loader: ^2.8.1 + react-live-chat-loader: ^2.7.3 swr: ^1.2.2 - tailwindcss: ^3.3.3 + tailwindcss: ^3.2.1 typescript: ^4.9.4 - zod: ^3.22.2 + zod: ^3.20.2 languageName: unknown linkType: soft @@ -8478,6 +8485,20 @@ __metadata: languageName: node linkType: hard +"@prisma/client@npm:^4.13.0": + version: 4.16.2 + resolution: "@prisma/client@npm:4.16.2" + dependencies: + "@prisma/engines-version": 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 + peerDependencies: + prisma: "*" + peerDependenciesMeta: + prisma: + optional: true + checksum: 38e1356644a764946c69c8691ea4bbed0ba37739d833a435625bd5435912bed4b9bdd7c384125f3a4ab8128faf566027985c0f0840a42741c338d72e40b5d565 + languageName: node + linkType: hard + "@prisma/client@npm:^5.4.2": version: 5.4.2 resolution: "@prisma/client@npm:5.4.2" @@ -8536,6 +8557,13 @@ __metadata: languageName: node linkType: hard +"@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81": + version: 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 + resolution: "@prisma/engines-version@npm:4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81" + checksum: b42c6abe7c1928e546f15449e40ffa455701ef2ab1f62973628ecb4e19ff3652e34609a0d83196d1cbd0864adb44c55e082beec852b11929acf1c15fb57ca45a + languageName: node + linkType: hard + "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574": version: 5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574 resolution: "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574" @@ -8543,6 +8571,13 @@ __metadata: languageName: node linkType: hard +"@prisma/engines@npm:4.16.2": + version: 4.16.2 + resolution: "@prisma/engines@npm:4.16.2" + checksum: f423e6092c3e558cd089a68ae87459fba7fd390c433df087342b3269c3b04163965b50845150dfe47d01f811781bfff89d5ae81c95ca603c59359ab69ebd810f + languageName: node + linkType: hard + "@prisma/engines@npm:5.1.1": version: 5.1.1 resolution: "@prisma/engines@npm:5.1.1" @@ -24668,6 +24703,13 @@ __metadata: languageName: node linkType: hard +"i18next-fs-backend@npm:^1.1.4": + version: 1.2.0 + resolution: "i18next-fs-backend@npm:1.2.0" + checksum: da74d20f2b007f8e34eaf442fa91ad12aaff3b9891e066c6addd6d111b37e370c62370dfbc656730ab2f8afd988f2e7ea1c48301ebb19ccb716fb5965600eddf + languageName: node + linkType: hard + "i18next-fs-backend@npm:^2.1.1": version: 2.1.3 resolution: "i18next-fs-backend@npm:2.1.3" @@ -24675,6 +24717,15 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^21.8.13": + version: 21.10.0 + resolution: "i18next@npm:21.10.0" + dependencies: + "@babel/runtime": ^7.17.2 + checksum: f997985e2d4d15a62a0936a82ff6420b97f3f971e776fe685bdd50b4de0cb4dc2198bc75efe6b152844794ebd5040d8060d6d152506a687affad534834836d81 + languageName: node + linkType: hard + "i18next@npm:^23.2.3": version: 23.2.3 resolution: "i18next@npm:23.2.3" @@ -26343,6 +26394,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^1.19.1": + version: 1.21.0 + resolution: "jiti@npm:1.21.0" + bin: + jiti: bin/jiti.js + checksum: a7bd5d63921c170eaec91eecd686388181c7828e1fa0657ab374b9372bfc1f383cf4b039e6b272383d5cb25607509880af814a39abdff967322459cca41f2961 + languageName: node + linkType: hard + "joi@npm:^17.7.0": version: 17.10.2 resolution: "joi@npm:17.10.2" @@ -30069,6 +30129,31 @@ __metadata: languageName: node linkType: hard +"next-auth@npm:^4.20.1": + version: 4.24.5 + resolution: "next-auth@npm:4.24.5" + dependencies: + "@babel/runtime": ^7.20.13 + "@panva/hkdf": ^1.0.2 + cookie: ^0.5.0 + jose: ^4.11.4 + oauth: ^0.9.15 + openid-client: ^5.4.0 + preact: ^10.6.3 + preact-render-to-string: ^5.1.19 + uuid: ^8.3.2 + peerDependencies: + next: ^12.2.5 || ^13 || ^14 + nodemailer: ^6.6.5 + react: ^17.0.2 || ^18 + react-dom: ^17.0.2 || ^18 + peerDependenciesMeta: + nodemailer: + optional: true + checksum: 7cc49385123690ccb908f4552b75012717c4e45205a9fdc7cf48cd730dbcc7823a3e33e2a2073ecf1edae5c1980123f68678fd4af9198ea21ab0decb630cc71e + languageName: node + linkType: hard + "next-auth@npm:^4.22.1": version: 4.22.1 resolution: "next-auth@npm:4.22.1" @@ -30136,6 +30221,24 @@ __metadata: languageName: node linkType: hard +"next-i18next@npm:^11.3.0": + version: 11.3.0 + resolution: "next-i18next@npm:11.3.0" + dependencies: + "@babel/runtime": ^7.18.6 + "@types/hoist-non-react-statics": ^3.3.1 + core-js: ^3 + hoist-non-react-statics: ^3.3.2 + i18next: ^21.8.13 + i18next-fs-backend: ^1.1.4 + react-i18next: ^11.18.0 + peerDependencies: + next: ">= 10.0.0" + react: ">= 16.8.0" + checksum: fbce97a4fbf9ad846c08652471a833c7f173c3e7ddc7cafa1423625b4a684715bb85f76ae06fe9cbed3e70f12b8e78e2459e5bc1a3c3f5c517743f17648f8939 + languageName: node + linkType: hard + "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::locator=calcom-monorepo%40workspace%3A.": version: 13.3.0 resolution: "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::version=13.3.0&hash=bcbde7&locator=calcom-monorepo%40workspace%3A." @@ -30217,7 +30320,7 @@ __metadata: languageName: node linkType: hard -"next@npm:^13.4.6": +"next@npm:^13.2.1, next@npm:^13.4.6": version: 13.5.6 resolution: "next@npm:13.5.6" dependencies: @@ -32891,6 +32994,18 @@ __metadata: languageName: node linkType: hard +"prisma@npm:^4.13.0": + version: 4.16.2 + resolution: "prisma@npm:4.16.2" + dependencies: + "@prisma/engines": 4.16.2 + bin: + prisma: build/index.js + prisma2: build/index.js + checksum: 1d0ed616abd7f8de22441e333b976705f1cb05abcb206965df3fc6a7ea03911ef467dd484a4bc51fdc6cff72dd9857b9852be5f232967a444af0a98c49bfdb76 + languageName: node + linkType: hard + "prisma@npm:^5.4.2": version: 5.4.2 resolution: "prisma@npm:5.4.2" @@ -33788,6 +33903,24 @@ __metadata: languageName: node linkType: hard +"react-i18next@npm:^11.18.0": + version: 11.18.6 + resolution: "react-i18next@npm:11.18.6" + dependencies: + "@babel/runtime": ^7.14.5 + html-parse-stringify: ^3.0.1 + peerDependencies: + i18next: ">= 19.0.0" + react: ">= 16.8.0" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 624c0a0313fac4e0d18560b83c99a8bd0a83abc02e5db8d01984e0643ac409d178668aa3a4720d01f7a0d9520d38598dcbff801d6f69a970bae67461de6cd852 + languageName: node + linkType: hard + "react-i18next@npm:^12.2.0": version: 12.3.1 resolution: "react-i18next@npm:12.3.1" @@ -33911,6 +34044,15 @@ __metadata: languageName: node linkType: hard +"react-live-chat-loader@npm:^2.7.3": + version: 2.8.2 + resolution: "react-live-chat-loader@npm:2.8.2" + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 + checksum: 30de0d27693f1c80641347f0efc9c846e0c8d52231eb3181b68d684ef580764d3bd8393d77ff61f3066af5cc65977fc1a108726965181ddbbd6a0feb0a9ebcb9 + languageName: node + linkType: hard + "react-live-chat-loader@npm:^2.8.1": version: 2.8.1 resolution: "react-live-chat-loader@npm:2.8.1" @@ -37775,6 +37917,39 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:^3.2.1": + version: 3.3.5 + resolution: "tailwindcss@npm:3.3.5" + dependencies: + "@alloc/quick-lru": ^5.2.0 + arg: ^5.0.2 + chokidar: ^3.5.3 + didyoumean: ^1.2.2 + dlv: ^1.1.3 + fast-glob: ^3.3.0 + glob-parent: ^6.0.2 + is-glob: ^4.0.3 + jiti: ^1.19.1 + lilconfig: ^2.1.0 + micromatch: ^4.0.5 + normalize-path: ^3.0.0 + object-hash: ^3.0.0 + picocolors: ^1.0.0 + postcss: ^8.4.23 + postcss-import: ^15.1.0 + postcss-js: ^4.0.1 + postcss-load-config: ^4.0.1 + postcss-nested: ^6.0.1 + postcss-selector-parser: ^6.0.11 + resolve: ^1.22.2 + sucrase: ^3.32.0 + bin: + tailwind: lib/cli.js + tailwindcss: lib/cli.js + checksum: e04bb3bb7f9f17e9b6db0c7ace755ef0d6d05bff36ebeb9e5006e13c018ed5566f09db30a1a34380e38fa93ebbb4ae0e28fe726879d5e9ddd8c5b52bffd26f14 + languageName: node + linkType: hard + "tailwindcss@npm:^3.3.3": version: 3.3.3 resolution: "tailwindcss@npm:3.3.3" @@ -41955,6 +42130,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.20.2": + version: 3.22.4 + resolution: "zod@npm:3.22.4" + checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f + languageName: node + linkType: hard + "zod@npm:^3.21.4, zod@npm:^3.22.2": version: 3.22.2 resolution: "zod@npm:3.22.2" From c11f7aeffc8fc9a0d8fd082e5f1aad43ac8bbb82 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 23 Nov 2023 21:25:27 +0000 Subject: [PATCH 025/292] chore: insights UI refresher (#12498) Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Alex van Andel --- apps/web/pages/insights/index.tsx | 6 +- .../ee/common/components/LicenseRequired.tsx | 10 +-- .../insights/components/BookingKPICards.tsx | 4 +- .../insights/components/LineChart.tsx | 6 +- .../features/insights/filters/DateSelect.css | 68 +++++++++++++++++-- .../features/insights/filters/DateSelect.tsx | 3 +- packages/features/insights/filters/index.tsx | 2 +- 7 files changed, 77 insertions(+), 22 deletions(-) diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx index 1526bb6660..0c0dd0b60b 100644 --- a/apps/web/pages/insights/index.tsx +++ b/apps/web/pages/insights/index.tsx @@ -69,17 +69,17 @@ export default function InsightsPage() { -
    +
    -
    +
    -
    +
    diff --git a/packages/features/ee/common/components/LicenseRequired.tsx b/packages/features/ee/common/components/LicenseRequired.tsx index e34134cc97..411eb44c1a 100644 --- a/packages/features/ee/common/components/LicenseRequired.tsx +++ b/packages/features/ee/common/components/LicenseRequired.tsx @@ -5,7 +5,7 @@ import React, { Fragment, useEffect } from "react"; import { SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { EmptyScreen, TopBanner } from "@calcom/ui"; +import { EmptyScreen, Alert } from "@calcom/ui"; import { AlertTriangle } from "@calcom/ui/components/icon"; type LicenseRequiredProps = { @@ -37,9 +37,10 @@ const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) = ) : process.env.NODE_ENV === "development" ? ( /** We only show a warning in development mode, but allow the feature to be displayed for development/testing purposes */ <> - {t("enterprise_license")}.{" "} @@ -52,7 +53,6 @@ const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) = } - variant="warning" /> {children} diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/BookingKPICards.tsx index 2e385dfda9..71e125cecc 100644 --- a/packages/features/insights/components/BookingKPICards.tsx +++ b/packages/features/insights/components/BookingKPICards.tsx @@ -67,7 +67,7 @@ export const BookingKPICards = () => { return ( <> - + {categories.map((item) => ( { const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => { const { categories } = props; return ( - + {categories.map((item) => ( diff --git a/packages/features/insights/components/LineChart.tsx b/packages/features/insights/components/LineChart.tsx index 0c338f44bc..165a9df811 100644 --- a/packages/features/insights/components/LineChart.tsx +++ b/packages/features/insights/components/LineChart.tsx @@ -4,9 +4,5 @@ import type { LineChartProps } from "./tremor.types"; // Honestly this is a mess. Why are all chart libraries in existance horrible to theme export const LineChart = (props: LineChartProps) => { - return ( -
    - -
    - ); + return ; }; diff --git a/packages/features/insights/filters/DateSelect.css b/packages/features/insights/filters/DateSelect.css index 054f04208a..4b024872fb 100644 --- a/packages/features/insights/filters/DateSelect.css +++ b/packages/features/insights/filters/DateSelect.css @@ -1,6 +1,7 @@ .custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button { box-shadow: none; width: 100%; + background-color: transparent; } /* Media query for screens larger than 768px */ @@ -10,23 +11,82 @@ } } +.recharts-cartesian-grid-horizontal line{ + @apply stroke-emphasis +} + +.tremor-DateRangePicker-button button{ + @apply !h-9 !max-h-9 border-default hover:border-emphasis +} + .tremor-DateRangePicker-calendarButton, .tremor-DateRangePicker-dropdownButton { @apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0; } +.tremor-DateRangePicker-dropdownModal{ + @apply divide-none +} + +.tremor-DropdownItem-root{ + @apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis +} + .tremor-DateRangePicker-calendarButtonText, .tremor-DateRangePicker-dropdownButtonText { @apply text-default; } -.tremor-DateRangePicker-calendarModal, -.tremor-DateRangePicker-dropdownModal { - @apply dark:invert; +.tremor-DateRangePicker-calendarHeaderText{ + @apply !text-default } +.tremor-DateRangePicker-calendarHeader svg{ + @apply text-default +} + +.tremor-DateRangePicker-calendarHeader button{ + @apply hover:bg-emphasis shadow-none focus:ring-0 +} + + +.tremor-DateRangePicker-calendarHeader button:hover svg{ + @apply text-emphasis +} + +.tremor-DateRangePicker-calendarButtonIcon{ + @apply text-default +} + +.tremor-DateRangePicker-calendarModal, +.tremor-DateRangePicker-dropdownModal { + @apply bg-default border-subtle shadow-dropdown +} + +.tremor-DateRangePicker-calendarBodyDate button{ + @apply text-default hover:bg-emphasis +} + +.tremor-DateRangePicker-calendarBodyDate button:disabled, +.tremor-DateRangePicker-calendarBodyDate button[disabled]{ + @apply opacity-25 +} + +.tremor-DateRangePicker-calendarHeader button{ + @apply border-default text-default +} + +.tremor-DateRangePicker-calendarBodyDate .bg-gray-100{ + @apply bg-subtle +} + +.tremor-DateRangePicker-calendarBodyDate .bg-gray-500{ + @apply !bg-brand-default text-inverted +} + + .tremor-Card-root { - @apply p-5; + @apply p-5 bg-default; } .tremor-TableCell-root { diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx index b4795287e5..d16630bfdb 100644 --- a/packages/features/insights/filters/DateSelect.tsx +++ b/packages/features/insights/filters/DateSelect.tsx @@ -16,7 +16,7 @@ export const DateSelect = () => { const startValue = startDate?.toDate() || null; const endValue = endDate?.toDate() || null; return ( -
    +
    { minDate={currentDate.subtract(2, "year").toDate()} maxDate={currentDate.toDate()} color="gray" - className="h-[42px]" />
    ); diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx index cc5ab30a8b..a0521ca869 100644 --- a/packages/features/insights/filters/index.tsx +++ b/packages/features/insights/filters/index.tsx @@ -37,7 +37,7 @@ const ClearFilters = () => { export const Filters = () => { return ( -
    +
    From 0ddefaa141b5ef61e4a9a68fc1ce28188d49f8df Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 23 Nov 2023 23:42:18 +0000 Subject: [PATCH 026/292] Remove cache-control and disable cache (#12517) --- apps/web/pages/api/user/avatar.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index da86db7bac..11512759fd 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -105,8 +105,6 @@ async function getIdentityData(req: NextApiRequest) { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const identity = await getIdentityData(req); const img = identity?.avatar; - // We cache for one day - res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60"); // If image isn't set or links to this route itself, use default avatar if (!img) { if (identity?.org) { From fca5778b6d5f6cef1e2c3ebf1f4b8dad6f4c07e7 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 23 Nov 2023 23:43:14 +0000 Subject: [PATCH 027/292] chore: avatar padding (#12515) --- apps/web/pages/[user].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 46a0e5ffa5..7d5cf4f9f7 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -116,7 +116,7 @@ export function UserPage(props: InferGetServerSidePropsType -

    +

    {profile.name} {user.verified && ( From 5df41e37a0b5b89551c32c9504c9558c1a0b05f2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Nov 2023 10:18:06 -0300 Subject: [PATCH 028/292] fix: Stripe webhook event mismatch (#12522) --- .env.example | 1 + apps/web/pages/api/integrations/subscriptions/webhook.ts | 6 +++--- packages/types/environment.d.ts | 1 + turbo.json | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 6d71f204ea..64a1bfcf77 100644 --- a/.env.example +++ b/.env.example @@ -155,6 +155,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE= STRIPE_TEAM_MONTHLY_PRICE_ID= STRIPE_ORG_MONTHLY_PRICE_ID= STRIPE_WEBHOOK_SECRET= +STRIPE_WEBHOOK_SECRET_APPS= STRIPE_PRIVATE_KEY= STRIPE_CLIENT_ID= PAYMENT_FEE_FIXED= diff --git a/apps/web/pages/api/integrations/subscriptions/webhook.ts b/apps/web/pages/api/integrations/subscriptions/webhook.ts index 63f4cba477..c1bb8b3790 100644 --- a/apps/web/pages/api/integrations/subscriptions/webhook.ts +++ b/apps/web/pages/api/integrations/subscriptions/webhook.ts @@ -83,13 +83,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" }); } - if (!process.env.STRIPE_WEBHOOK_SECRET) { - throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" }); + if (!process.env.STRIPE_WEBHOOK_SECRET_APPS) { + throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET_APPS" }); } const requestBuffer = await buffer(req); const payload = requestBuffer.toString(); - const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET); + const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET_APPS); const handler = webhookHandlers[event.type]; if (handler) { diff --git a/packages/types/environment.d.ts b/packages/types/environment.d.ts index 006be0478d..caee882791 100644 --- a/packages/types/environment.d.ts +++ b/packages/types/environment.d.ts @@ -27,6 +27,7 @@ declare namespace NodeJS { readonly STRIPE_PRIVATE_KEY: string | undefined; readonly STRIPE_CLIENT_ID: string | undefined; readonly STRIPE_WEBHOOK_SECRET: string | undefined; + readonly STRIPE_WEBHOOK_SECRET_APPS: string | undefined; readonly PAYMENT_FEE_PERCENTAGE: number | undefined; readonly PAYMENT_FEE_FIXED: number | undefined; readonly NEXT_PUBLIC_INTERCOM_APP_ID: string | undefined; diff --git a/turbo.json b/turbo.json index 0d7226a8ec..4344ae0f25 100644 --- a/turbo.json +++ b/turbo.json @@ -312,6 +312,7 @@ "STRIPE_PRODUCT_ID_SCALE", "STRIPE_PRODUCT_ID_STARTER", "STRIPE_WEBHOOK_SECRET", + "STRIPE_WEBHOOK_SECRET_APPS", "TANDEM_BASE_URL", "TANDEM_CLIENT_ID", "TANDEM_CLIENT_SECRET", From a6df49235fdcdaaea99334cacaa836782131b257 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 24 Nov 2023 15:18:08 +0100 Subject: [PATCH 029/292] chore: sync platform branch with main (#12525) --- .github/workflows/sync-platform-branch.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/sync-platform-branch.yml diff --git a/.github/workflows/sync-platform-branch.yml b/.github/workflows/sync-platform-branch.yml new file mode 100644 index 0000000000..86d90632a1 --- /dev/null +++ b/.github/workflows/sync-platform-branch.yml @@ -0,0 +1,20 @@ +name: Sync platform branch with the main branch + +on: + push: + branches: + - main + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Sync with main + run: | + git checkout platform + git pull origin main + git push origin platform From d595aa23c0f9d1b6e595fda227c8d7ecb324b9ad Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 24 Nov 2023 16:00:01 +0100 Subject: [PATCH 030/292] fix: platform branch sync with main (#12526) --- .github/workflows/sync-platform-branch.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sync-platform-branch.yml b/.github/workflows/sync-platform-branch.yml index 86d90632a1..b5bc5a245c 100644 --- a/.github/workflows/sync-platform-branch.yml +++ b/.github/workflows/sync-platform-branch.yml @@ -13,6 +13,9 @@ jobs: - name: Checkout code uses: actions/checkout@v2 + - name: Fetch platform branch + run: git fetch origin platform + - name: Sync with main run: | git checkout platform From ed4cce1c7e6a5b96f572fec4d3f4bbcb460db0f3 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 24 Nov 2023 17:46:33 +0100 Subject: [PATCH 031/292] fix: platform branch sync with main (#12528) --- .github/workflows/sync-platform-branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-platform-branch.yml b/.github/workflows/sync-platform-branch.yml index b5bc5a245c..604fb7174e 100644 --- a/.github/workflows/sync-platform-branch.yml +++ b/.github/workflows/sync-platform-branch.yml @@ -19,5 +19,5 @@ jobs: - name: Sync with main run: | git checkout platform - git pull origin main + git pull origin main --no-rebase git push origin platform From 0a43aa33516576e410f7f4338df6c0ec4cfa7282 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Fri, 24 Nov 2023 12:38:21 -0500 Subject: [PATCH 032/292] OAuth URL account for dev environment (#12530) --- packages/lib/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 1034db6d0d..1de85d4fcb 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -5,6 +5,7 @@ const RENDER_URL = process.env.RENDER_EXTERNAL_URL ? `https://${process.env.REND export const CALCOM_ENV = process.env.CALCOM_ENV || process.env.NODE_ENV; export const IS_PRODUCTION = CALCOM_ENV === "production"; export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production"; +const IS_DEV = CALCOM_ENV === "development"; /** https://app.cal.com */ export const WEBAPP_URL = @@ -17,7 +18,7 @@ export const WEBAPP_URL = // OAuth needs to have HTTPS(which is not generally setup locally) and a valid tld(*.local isn't a valid tld) // So for development purpose, we would stick to localhost only -export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION ? WEBAPP_URL : "http://localhost:3000"; +export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000"; /** @deprecated use `WEBAPP_URL` */ export const BASE_URL = WEBAPP_URL; From b0a1ef8a49e4a2a7074638d8023c1e48a6070cb0 Mon Sep 17 00:00:00 2001 From: DmytroHryshyn <125881252+DmytroHryshyn@users.noreply.github.com> Date: Fri, 24 Nov 2023 21:12:43 +0200 Subject: [PATCH 033/292] chore: remove dynamic loading of EventTypeDescription on event-types page (#12524) --- apps/web/pages/event-types/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 3b6fcb599f..d9f32aeeb5 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -13,7 +13,7 @@ import { getLayout } from "@calcom/features/MainLayout"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom"; import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed"; -import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; +import { EventTypeDescription } from "@calcom/features/eventtypes/components"; import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog"; import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog"; import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter"; From cb78de231a66d38f9af0836232d0423ff146fca9 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Sat, 25 Nov 2023 03:24:14 +0530 Subject: [PATCH 034/292] feat: if profile only has one public event-type, redirect to it (#12158) Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/web/components/ui/avatar/UserAvatarGroup.tsx | 2 ++ .../components/ui/avatar/UserAvatarGroupWithOrg.tsx | 7 ++++++- apps/web/pages/[user].tsx | 11 +++++++++++ apps/web/pages/org/[orgSlug]/[user]/index.tsx | 2 +- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/components/ui/avatar/UserAvatarGroup.tsx b/apps/web/components/ui/avatar/UserAvatarGroup.tsx index ad3909641e..e9346fb401 100644 --- a/apps/web/components/ui/avatar/UserAvatarGroup.tsx +++ b/apps/web/components/ui/avatar/UserAvatarGroup.tsx @@ -1,3 +1,4 @@ +import { CAL_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { User } from "@calcom/prisma/client"; import { AvatarGroup } from "@calcom/ui"; @@ -11,6 +12,7 @@ export function UserAvatarGroup(props: UserAvatarProps) { ({ + href: `${CAL_URL}/${user.username}?redirect=false`, alt: user.name || "", title: user.name || "", image: getUserAvatarUrl(user), diff --git a/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx index 9de57a0b57..6b265395d8 100644 --- a/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx +++ b/apps/web/components/ui/avatar/UserAvatarGroupWithOrg.tsx @@ -1,4 +1,5 @@ -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { Team, User } from "@calcom/prisma/client"; import { AvatarGroup } from "@calcom/ui"; @@ -10,8 +11,11 @@ type UserAvatarProps = Omit, "items"> & export function UserAvatarGroupWithOrg(props: UserAvatarProps) { const { users, organization, ...rest } = props; + const orgBranding = useOrgBranding(); + const baseUrl = `${orgBranding?.fullDomain ?? CAL_URL}`; const items = [ { + href: baseUrl, image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`, alt: organization.name || undefined, title: organization.name, @@ -19,6 +23,7 @@ export function UserAvatarGroupWithOrg(props: UserAvatarProps) { ].concat( users.map((user) => { return { + href: `${baseUrl}/${user.username}/?redirect=false`, image: getUserAvatarUrl(user), alt: user.name || undefined, title: user.name || user.username || "", diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 7d5cf4f9f7..493b8f3953 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -54,6 +54,7 @@ export function UserPage(props: InferGetServerSidePropsType = async (cont descriptionAsSafeHTML: markdownToSafeHTML(eventType.description), })); + // if profile only has one public event-type, redirect to it + if (eventTypes.length === 1 && context.query.redirect !== "false") { + return { + redirect: { + permanent: false, + destination: `/${user.username}/${eventTypes[0].slug}`, + }, + }; + } + const safeBio = markdownToSafeHTML(user.bio) || ""; const markdownStrippedBio = stripMarkdown(user?.bio || ""); diff --git a/apps/web/pages/org/[orgSlug]/[user]/index.tsx b/apps/web/pages/org/[orgSlug]/[user]/index.tsx index 7174a217d7..df0d6d6b97 100644 --- a/apps/web/pages/org/[orgSlug]/[user]/index.tsx +++ b/apps/web/pages/org/[orgSlug]/[user]/index.tsx @@ -26,7 +26,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (team) { return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } }); } - return GSSUserPage({ ...ctx, query: { user: ctx.query.user } }); + return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } }); }; type Props = UserPageProps | TeamPageProps; From dabd5eae736090c30d400b0a1e63621ab045c8cf Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Sat, 25 Nov 2023 16:55:40 +0000 Subject: [PATCH 035/292] chore: fixed order of tips (#12529) --- apps/web/public/static/locales/de/common.json | 34 ++-- packages/features/tips/Tips.tsx | 169 +++++++++--------- 2 files changed, 101 insertions(+), 102 deletions(-) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index a9da73eb13..7c0859d374 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -155,7 +155,7 @@ "webhook_updated_successfully": "Webhook erfolgreich aktualisiert!", "webhook_removed_successfully": "Webhook erfolgreich entfernt!", "payload_template": "Payload Vorlage", - "dismiss": "Abbrechen", + "dismiss": "Ignorieren", "no_data_yet": "Noch keine Daten", "ping_test": "Pingtest", "add_to_homescreen": "Fügen Sie diese App Ihrem Startbildschirm für schnelleren Zugriff hinzu.", @@ -295,7 +295,7 @@ "other": "Sonstige", "email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}", "emailed_you_and_attendees": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.", - "emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Ereignisse an alle gesendet.", + "emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Termine an alle gesendet.", "emailed_you_and_any_other_attendees": "Wir haben eine E-Mail mit diesen Informationen an alle gesendet.", "needs_to_be_confirmed_or_rejected": "Ihr Termin muss noch bestätigt oder abgelehnt werden.", "needs_to_be_confirmed_or_rejected_recurring": "Ihr wiederkehrender Termin muss noch bestätigt oder abgelehnt werden.", @@ -683,7 +683,7 @@ "plan_upgrade_teams": "Du musst deinen Plan upgraden, um ein Team zu erstellen.", "plan_upgrade_instructions": "Sie können <1>hier upgraden.", "event_types_page_title": "Ereignistypen", - "event_types_page_subtitle": "Erstellen Sie teilbare Ereignisse, die andere Personen buchen können.", + "event_types_page_subtitle": "Erstellen Sie teilbare Termine, die andere Personen buchen können.", "new": "Neu", "new_event_type_btn": "Neuer Ereignistyp", "new_event_type_heading": "Erstellen Sie Ihren ersten Ereignistyp", @@ -703,7 +703,7 @@ "repeats_up_to_other": "Wiederholt sich bis zu {{count}} mal", "every_for_freq": "Alle {{freq}} für", "event_remaining_one": "{{count}} Ereignis übrig", - "event_remaining_other": "{{count}} Ereignisse übrig", + "event_remaining_other": "{{count}} Termine übrig", "repeats_every": "Wiederholt sich alle", "occurrence_one": "Vorkommnis", "occurrence_other": "Vorkommnisse", @@ -1110,7 +1110,7 @@ "zapier_invite_link": "Zapier Einladungs-Link", "meeting_url_provided_after_confirmed": "Eine Termin-URL wird angelegt, sobald der Termin bestätigt wurde.", "dynamically_display_attendee_or_organizer": "Zeigt dynamisch entweder Ihnen den Namen Ihres Teilnehmers bzw. Ihrer Teilnehmerin an oder zeigt Ihrem/Ihrer Teilnehmerin Ihren Namen an", - "event_location": "Ort des Ereignisses", + "event_location": "Ort des Termins", "reschedule_optional": "Grund für die Verschiebung (optional)", "reschedule_placeholder": "Lassen Sie andere wissen, warum Sie den Termin verschieben müssen", "event_cancelled": "Dieser Termin ist abgesagt", @@ -1277,7 +1277,7 @@ "2fa_required": "Zwei-Faktor-Authentifizierung erforderlich", "incorrect_2fa": "Falscher Zwei-Faktor-Authentifizierungscode", "which_event_type_apply": "Auf welchen Termintyp wird dies angewandt?", - "no_workflows_description": "Workflows ermöglichen die einfache Automatisierung des Versands von Benachrichtigungen und Erinnerungen, so dass Sie Prozesse rund um Ihre Ereignisse erstellen können.", + "no_workflows_description": "Workflows ermöglichen die einfache Automatisierung des Versands von Benachrichtigungen und Erinnerungen, so dass Sie Prozesse rund um Ihre Termine erstellen können.", "timeformat_profile_hint": "Dies ist eine interne Einstellung, die keinen Einfluss darauf hat, wie die Zeiten auf den öffentlichen Buchungsseiten für Sie oder jemanden, der Sie buchen möchte, angezeigt werden.", "create_workflow": "Einen Workflow erstellen", "do_this": "Mache dies", @@ -1362,7 +1362,7 @@ "add_dynamic_variables": "Dynamische Textvariablen hinzufügen", "event_name_info": "Name des Ereignistyps", "event_date_info": "Das Datum der Veranstaltung", - "event_time_info": "Die Startzeit des Ereignisses", + "event_time_info": "Die Startzeit des Termins", "location_info": "Der Ort des Events", "additional_notes_info": "Die zusätzlichen Anmerkungen der Buchung", "attendee_name_info": "Name der buchenden Person", @@ -1380,7 +1380,7 @@ "create_your_first_webhook_description": "Mit Webhooks können Sie Meetingdaten in Echtzeit erhalten, sobald etwas in {{appName}} passiert.", "for_a_maximum_of": "Für maximal", "event_one": "Ereignis", - "event_other": "Ereignisse", + "event_other": "Termine", "profile_team_description": "Einstellungen für Ihr Teamprofil verwalten", "profile_org_description": "Einstellungen für Ihr Organization-Profil verwalten", "members_team_description": "Benutzer in der Gruppe", @@ -1502,13 +1502,13 @@ "require_additional_notes": "Zusätzliche Notizen erforderlich", "require_additional_notes_description": "Zusätzliche Notizen bei der Buchung erforderlich machen", "email_address_action": "eine E-Mail an eine bestimmte E-Mail-Adresse senden", - "after_event_trigger": "nach Ende des Ereignisses", - "how_long_after": "Wie lange nach Ende des Ereignisses?", + "after_event_trigger": "nach Ende des Termins", + "how_long_after": "Wie lange nach Ende des Termins?", "no_available_slots": "Keine verfügbaren Plätze", "time_available": "Zeit verfügbar", "cant_find_the_right_video_app_visit_our_app_store": "Sie können die richtige Video-App nicht finden? Besuchen Sie unseren <1>App Store.", "install_new_calendar_app": "Neue Kalender-App installieren", - "make_phone_number_required": "Telefonnummer für die Buchung des Ereignisses erforderlich machen", + "make_phone_number_required": "Telefonnummer für die Buchung des Termins erforderlich machen", "new_event_type_availability": "{{eventTypeTitle}} | Verfügbarkeit", "error_editing_availability": "Fehler beim Bearbeiten der Verfügbarkeit", "dont_have_permission": "Ihnen fehlt die Berechtigung, auf diese Ressource zuzugreifen.", @@ -1813,7 +1813,7 @@ "can_you_try_again": "Können Sie es zu einem anderen Zeitpunkt erneut versuchen?", "verify": "Bestätigen", "timezone_info": "Die Zeitzone der empfangenden Person", - "event_end_time_variable": "Endzeitpunkt des Ereignisses", + "event_end_time_variable": "Endzeitpunkt des Termins", "event_end_time_info": "Der Endzeitpunkt des Termins", "cancel_url_variable": "Absage-URL", "cancel_url_info": "Die URL, um die Buchung abzusagen", @@ -1849,15 +1849,15 @@ "select_user": "Benutzer auswählen", "select_event_type": "Ereignistyp auswählen", "select_date_range": "Datumsbereich auswählen", - "popular_events": "Beliebte Ereignisse", + "popular_events": "Beliebte Termine", "no_event_types_found": "Keine Ereignistypen gefunden", "average_event_duration": "Durchschnittliche Ereignisdauer", "most_booked_members": "Meistgebuchte Mitglieder", "least_booked_members": "Am wenigsten gebuchte Mitglieder", - "events_created": "Ereignisse erstellt", - "events_completed": "Ereignisse abgeschlossen", + "events_created": "Termine erstellt", + "events_completed": "Termine abgeschlossen", "events_cancelled": "Termine abgesagt", - "events_rescheduled": "Ereignisse neu geplant", + "events_rescheduled": "Termine neu geplant", "from_last_period": "seit dem letzten Zeitraum", "from_to_date_period": "Von: {{startDate}} Bis: {{endDate}}", "redirect_url_warning": "Das Hinzufügen einer Umleitung wird die Erfolgsseite deaktivieren. Erwähnen Sie \"Buchung bestätigt\" auf Ihrer benutzerdefinierten Erfolgsseite.", @@ -1996,7 +1996,7 @@ "insights_all_org_filter": "Alle Apps", "insights_team_filter": "Team: {{teamName}}", "insights_user_filter": "Benutzer: {{userName}}", - "insights_subtitle": "Sehen Sie sich Buchungs-Insights zu Ihren Termine an", + "insights_subtitle": "Erfahren Sie mehr über Ihre Termine und Ihr Team", "location_options": "{{locationCount}} Veranstaltungsort-Optionen", "custom_plan": "Maßgeschneiderter Tarif", "email_embed": "E-Mail Einbettung", diff --git a/packages/features/tips/Tips.tsx b/packages/features/tips/Tips.tsx index 3ac41350c3..c5c24a10cc 100644 --- a/packages/features/tips/Tips.tsx +++ b/packages/features/tips/Tips.tsx @@ -6,84 +6,12 @@ import { Card } from "@calcom/ui"; export const tips = [ { - id: 1, - thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg", - mediaLink: "https://go.cal.com/dynamic-video", - title: "Dynamic booking links", - description: "Booking link that allows people to quickly schedule meetings.", - href: "https://cal.com/blog/cal-v-1-9", - }, - { - id: 2, - thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg", - mediaLink: "https://go.cal.com/teams-video", - title: "How to set up Teams", - description: "Learn how to use round-robin and collective events.", - href: "https://cal.com/docs/enterprise-features/teams", - }, - { - id: 3, - thumbnailUrl: "https://img.youtube.com/vi/c7ZKFuLy1fg/0.jpg", - mediaLink: "https://go.cal.com/routing-video", - title: "Routing Forms, Workflows", - description: "Ask screening questions of potential bookers to connect them with the right person", - href: "https://cal.com/blog/cal-v-1-8", - }, - { - id: 4, - thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg", - mediaLink: "https://go.cal.com/confirmation-video", - title: "Requires Confirmation", - description: "Learn how to be in charge of your bookings", - href: "https://cal.com/resources/feature/opt-in", - }, - { - id: 5, - thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg", - mediaLink: "https://go.cal.com/payments-video", - title: "Accept Payments", - description: "Charge for your time with Cal.com's Stripe App", - href: "https://app.cal.com/apps/stripe", - }, - { - id: 6, - thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg", - mediaLink: "https://go.cal.com/recurring-video", - title: "Recurring Bookings", - description: "Learn how to create a recurring schedule", - href: "https://go.cal.com/recurring-video", - }, - { - id: 7, - thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg", - mediaLink: "https://go.cal.com/routing-forms", - title: "Routing Forms", - description: "Ask questions and route to the correct person", - href: "https://go.cal.com/routing-forms", - }, - { - id: 8, - thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg", - mediaLink: "https://go.cal.com/workflows", - title: "Automate Workflows", - description: "Make time work for you and automate tasks", - href: "https://go.cal.com/workflows", - }, - { - id: 9, - thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg", - mediaLink: "https://go.cal.com/round-robin", - title: "Round-Robin", - description: "Create advanced group meetings with round-robin", - href: "https://go.cal.com/round-robin", - }, - { - id: 10, - thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg", - mediaLink: "https://go.cal.com/video", - title: "Cal Video", - description: "Free video conferencing with recording", - href: "https://go.cal.com/video", + id: 12, + thumbnailUrl: "https://cal.com/og-image-cal-ai.jpg", + mediaLink: "https://go.cal.com/cal-ai", + title: "Cal.ai", + description: "Your personal AI scheduling assistant", + href: "https://go.cal.com/cal-ai", }, { id: 11, @@ -94,13 +22,84 @@ export const tips = [ href: "https://go.cal.com/insights", }, { - id: 12, - thumbnailUrl: - "https://cal.com/og-image-cal-ai.jpg", - mediaLink: "https://go.cal.com/cal-ai", - title: "Cal.ai", - description: "Your personal AI scheduling assistant", - href: "https://go.cal.com/cal-ai", + id: 10, + thumbnailUrl: "https://img.youtube.com/vi/jvaBafzVUQc/0.jpg", + mediaLink: "https://go.cal.com/video", + title: "Cal Video", + description: "Free video conferencing with recording", + href: "https://go.cal.com/video", + }, + { + id: 9, + thumbnailUrl: "https://img.youtube.com/vi/93iOmzHieCU/0.jpg", + mediaLink: "https://go.cal.com/round-robin", + title: "Round-Robin", + description: "Create advanced group meetings with round-robin", + href: "https://go.cal.com/round-robin", + }, + { + id: 8, + thumbnailUrl: "https://img.youtube.com/vi/piKlAiibAFo/0.jpg", + mediaLink: "https://go.cal.com/workflows", + title: "Automate Workflows", + description: "Make time work for you and automate tasks", + href: "https://go.cal.com/workflows", + }, + { + id: 7, + thumbnailUrl: "https://img.youtube.com/vi/UVXgo12cY4g/0.jpg", + mediaLink: "https://go.cal.com/routing-forms", + title: "Routing Forms", + description: "Ask questions and route to the correct person", + href: "https://go.cal.com/routing-forms", + }, + { + id: 6, + thumbnailUrl: "https://img.youtube.com/vi/yGiZo1Ry5-8/0.jpg", + mediaLink: "https://go.cal.com/recurring-video", + title: "Recurring Bookings", + description: "Learn how to create a recurring schedule", + href: "https://go.cal.com/recurring-video", + }, + { + id: 5, + thumbnailUrl: "https://img.youtube.com/vi/0v_nQtpxC_4/0.jpg", + mediaLink: "https://go.cal.com/payments-video", + title: "Accept Payments", + description: "Charge for your time with Cal.com's Stripe App", + href: "https://app.cal.com/apps/stripe", + }, + { + id: 4, + thumbnailUrl: "https://img.youtube.com/vi/zGr_s-fG84k/0.jpg", + mediaLink: "https://go.cal.com/confirmation-video", + title: "Requires Confirmation", + description: "Learn how to be in charge of your bookings", + href: "https://cal.com/resources/feature/opt-in", + }, + { + id: 3, + thumbnailUrl: "https://img.youtube.com/vi/c7ZKFuLy1fg/0.jpg", + mediaLink: "https://go.cal.com/routing-video", + title: "Routing Forms, Workflows", + description: "Ask screening questions of potential bookers to connect them with the right person", + href: "https://cal.com/blog/cal-v-1-8", + }, + { + id: 2, + thumbnailUrl: "https://img.youtube.com/vi/EAc46SPL6iA/0.jpg", + mediaLink: "https://go.cal.com/teams-video", + title: "How to set up Teams", + description: "Learn how to use round-robin and collective events.", + href: "https://cal.com/docs/enterprise-features/teams", + }, + { + id: 1, + thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg", + mediaLink: "https://go.cal.com/dynamic-video", + title: "Dynamic booking links", + description: "Booking link that allows people to quickly schedule meetings.", + href: "https://cal.com/blog/cal-v-1-9", }, ]; From 5a869228d3f693e49d62ae2c8d54b64d5fa7531e Mon Sep 17 00:00:00 2001 From: Brijendra Singh <113870500+Brijendra-Singh2003@users.noreply.github.com> Date: Sun, 26 Nov 2023 14:34:40 +0530 Subject: [PATCH 036/292] typo (#12541) --- apps/web/pages/bookings/[status].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/pages/bookings/[status].tsx b/apps/web/pages/bookings/[status].tsx index db5dcb07a8..24b6cbc948 100644 --- a/apps/web/pages/bookings/[status].tsx +++ b/apps/web/pages/bookings/[status].tsx @@ -97,7 +97,7 @@ export default function Bookings() { } ); - // Animate page (tab) tranistions to look smoothing + // Animate page (tab) transitions to look smoothing const buttonInView = useInViewObserver(() => { if (!query.isFetching && query.hasNextPage && query.status === "success") { From 7b350a5b8fd3f21b26f5e7932dcf75254dfa9951 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Mon, 27 Nov 2023 09:35:58 +0100 Subject: [PATCH 037/292] revert: sync platform branch with main (#12548) --- .github/workflows/sync-platform-branch.yml | 23 ---------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/sync-platform-branch.yml diff --git a/.github/workflows/sync-platform-branch.yml b/.github/workflows/sync-platform-branch.yml deleted file mode 100644 index 604fb7174e..0000000000 --- a/.github/workflows/sync-platform-branch.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Sync platform branch with the main branch - -on: - push: - branches: - - main - -jobs: - sync: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Fetch platform branch - run: git fetch origin platform - - - name: Sync with main - run: | - git checkout platform - git pull origin main --no-rebase - git push origin platform From 2094d59856af24959e3b439074ac0e55bbc0dd58 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:27:27 +0200 Subject: [PATCH 038/292] refactor: invite members handler (#12442) * refactor: invite members handler * fixup! refactor: invite members handler * fixup! fixup! refactor: invite members handler * refactor: promise all settled send emails * fixup! refactor: promise all settled send emails * fixup! fixup! refactor: promise all settled send emails * fixup! fixup! fixup! refactor: promise all settled send emails * fix: opening team invite link in email throws error on signup page * fixup! Merge branch 'main' into cal-2698-refactor-invitemember-handler * fix: centralize validation if invitee can be invited * fix: improve select query and fix tests * fixup! Merge branch 'main' into cal-2698-refactor-invitemember-handler * rename functions and add some tests --- .../inviteMember/inviteMember.handler.ts | 175 ++++---- .../teams/inviteMember/inviteMember.schema.ts | 37 +- .../inviteMember/inviteMemberUtils.test.ts | 137 +++--- .../viewer/teams/inviteMember/utils.ts | 424 ++++++++++++------ 4 files changed, 459 insertions(+), 314 deletions(-) diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 5eaebe0844..41b081433b 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,28 +1,24 @@ -import { randomBytes } from "crypto"; - -import { sendTeamInviteEmail } from "@calcom/emails"; import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -import { isEmail } from "../util"; import type { TInviteMemberInputSchema } from "./inviteMember.schema"; import { checkPermissions, getTeamOrThrow, getEmailsToInvite, - getUserToInviteOrThrowIfExists, - checkInputEmailIsValid, getOrgConnectionInfo, - createNewUserConnectToOrgIfExists, - throwIfInviteIsToOrgAndUserExists, - createProvisionalMembership, getIsOrgVerified, sendVerificationEmail, - createAndAutoJoinIfInOrg, + getUsersToInvite, + createNewUsersConnectToOrgIfExists, + createProvisionalMemberships, + groupUsersByJoinability, + sendTeamInviteEmails, + sendEmails, } from "./utils"; type InviteMemberOptions = { @@ -33,10 +29,10 @@ type InviteMemberOptions = { }; export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) => { + const translation = await getTranslation(input.language ?? "en", "common"); await checkRateLimitAndThrowError({ identifier: `invitedBy:${ctx.user.id}`, }); - await checkPermissions({ userId: ctx.user.id, teamId: @@ -46,100 +42,81 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) = const team = await getTeamOrThrow(input.teamId, input.isOrg); const { autoAcceptEmailDomain, orgVerified } = getIsOrgVerified(input.isOrg, team); - - const translation = await getTranslation(input.language ?? "en", "common"); - const emailsToInvite = await getEmailsToInvite(input.usernameOrEmail); - - for (const usernameOrEmail of emailsToInvite) { - const connectionInfo = getOrgConnectionInfo({ - orgVerified, - orgAutoAcceptDomain: autoAcceptEmailDomain, - usersEmail: usernameOrEmail, - team, - isOrg: input.isOrg, - }); - const invitee = await getUserToInviteOrThrowIfExists({ - usernameOrEmail, - teamId: input.teamId, - isOrg: input.isOrg, - }); - - if (!invitee) { - checkInputEmailIsValid(usernameOrEmail); - - // valid email given, create User and add to team - await createNewUserConnectToOrgIfExists({ - usernameOrEmail, - input, - connectionInfo, - autoAcceptEmailDomain, - parentId: team.parentId, - }); - - await sendVerificationEmail({ usernameOrEmail, team, translation, ctx, input, connectionInfo }); - } else { - throwIfInviteIsToOrgAndUserExists(invitee, team, input.isOrg); - - const shouldAutoJoinOrgTeam = await createAndAutoJoinIfInOrg({ - invitee, - role: input.role, + const orgConnectInfoByEmail = emailsToInvite.reduce((acc, email) => { + return { + ...acc, + [email]: getOrgConnectionInfo({ + orgVerified, + orgAutoAcceptDomain: autoAcceptEmailDomain, + usersEmail: email, team, - }); - if (shouldAutoJoinOrgTeam.autoJoined) { - // Continue here because if this is true we dont need to send an email to the user - // we also dont need to update stripe as thats handled on an ORG level and not a team level. - continue; - } - - // create provisional membership - await createProvisionalMembership({ + isOrg: input.isOrg, + }), + }; + }, {} as Record>); + const existingUsersWithMembersips = await getUsersToInvite({ + usernameOrEmail: emailsToInvite, + isInvitedToOrg: input.isOrg, + team, + }); + const existingUsersEmails = existingUsersWithMembersips.map((user) => user.email); + const newUsersEmails = emailsToInvite.filter((email) => !existingUsersEmails.includes(email)); + // deal with users to create and invite to team/org + if (newUsersEmails.length) { + await createNewUsersConnectToOrgIfExists({ + usernamesOrEmails: newUsersEmails, + input, + connectionInfoMap: orgConnectInfoByEmail, + autoAcceptEmailDomain, + parentId: team.parentId, + }); + const sendVerifEmailsPromises = newUsersEmails.map((usernameOrEmail) => { + return sendVerificationEmail({ + usernameOrEmail, + team, + translation, + ctx, input, - invitee, + connectionInfo: orgConnectInfoByEmail[usernameOrEmail], }); + }); + sendEmails(sendVerifEmailsPromises); + } - let sendTo = usernameOrEmail; - if (!isEmail(usernameOrEmail)) { - sendTo = invitee.email; - } - // inform user of membership by email - if (ctx?.user?.name && team?.name) { - const inviteTeamOptions = { - joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, - isCalcomMember: true, - }; - /** - * Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template. - * This only changes if the user is a CAL user and has not completed onboarding and has no password - */ - if (!invitee.completedOnboarding && !invitee.password && invitee.identityProvider === "CAL") { - const token = randomBytes(32).toString("hex"); - await prisma.verificationToken.create({ - data: { - identifier: usernameOrEmail, - token, - expires: new Date(new Date().setHours(168)), // +1 week - team: { - connect: { - id: team.id, - }, - }, - }, - }); + // deal with existing users invited to join the team/org + if (existingUsersWithMembersips.length) { + const [autoJoinUsers, regularUsers] = groupUsersByJoinability({ + existingUsersWithMembersips, + team, + }); - inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; - inviteTeamOptions.isCalcomMember = false; - } + // invited users can autojoin, create their memberships in org + if (autoJoinUsers.length) { + await prisma.membership.createMany({ + data: autoJoinUsers.map((userToAutoJoin) => ({ + userId: userToAutoJoin.id, + teamId: team.id, + accepted: true, + role: input.role, + })), + }); + } - await sendTeamInviteEmail({ - language: translation, - from: ctx.user.name, - to: sendTo, - teamName: team.name, - ...inviteTeamOptions, - isOrg: input.isOrg, - }); - } + // invited users cannot autojoin, create provisional memberships and send email + if (regularUsers.length) { + await createProvisionalMemberships({ + input, + invitees: regularUsers, + }); + await sendTeamInviteEmails({ + currentUserName: ctx?.user?.name, + currentUserTeamName: team?.name, + existingUsersWithMembersips: regularUsers, + language: translation, + isOrg: input.isOrg, + teamId: team.id, + }); } } diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts index 69c7c24fe8..35c59a3523 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema.ts @@ -2,14 +2,39 @@ import { z } from "zod"; import { MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + export const ZInviteMemberInputSchema = z.object({ teamId: z.number(), - usernameOrEmail: z.union([z.string(), z.array(z.string())]).transform((usernameOrEmail) => { - if (typeof usernameOrEmail === "string") { - return usernameOrEmail.trim().toLowerCase(); - } - return usernameOrEmail.map((item) => item.trim().toLowerCase()); - }), + usernameOrEmail: z + .union([z.string(), z.array(z.string())]) + .transform((usernameOrEmail) => { + if (typeof usernameOrEmail === "string") { + return usernameOrEmail.trim().toLowerCase(); + } + 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.`, + }); + } + 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; + }), role: z.nativeEnum(MembershipRole), language: z.string(), isOrg: z.boolean().default(false), diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index da6a9a754a..6dc4ea15df 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -2,20 +2,19 @@ import { describe, it, vi, expect } from "vitest"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; -import type { User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import type { TeamWithParent } from "./types"; +import type { Invitee, UserWithMembership } from "./utils"; import { - checkInputEmailIsValid, checkPermissions, getEmailsToInvite, getIsOrgVerified, getOrgConnectionInfo, - throwIfInviteIsToOrgAndUserExists, - createAndAutoJoinIfInOrg, + validateInviteeEligibility, + shouldAutoJoinIfInOrg, } from "./utils"; vi.mock("@calcom/lib/server/queries", () => { @@ -60,46 +59,29 @@ const mockedTeam: TeamWithParent = { parentId: null, parent: null, isPrivate: false, + logoUrl: "", }; -const mockUser: User = { +const mockUser: Invitee = { id: 4, username: "pro", - name: "Pro Example", email: "pro@example.com", - emailVerified: new Date(), password: "", - bio: null, - avatar: null, - timeZone: "Europe/London", - weekStart: "Sunday", - startTime: 0, - endTime: 1440, - bufferTime: 0, - hideBranding: false, - theme: null, - createdDate: new Date(), - trialEndsAt: null, - defaultScheduleId: null, completedOnboarding: true, - locale: "en", - timeFormat: 12, - twoFactorSecret: null, - twoFactorEnabled: false, identityProvider: "CAL", - identityProviderId: null, - invitedTo: null, - brandColor: "#292929", - darkBrandColor: "#fafafa", - away: false, - allowDynamicBooking: true, - metadata: null, - verified: false, - role: "USER", - disableImpersonation: false, organizationId: null, }; +const userInTeamAccepted: UserWithMembership = { + ...mockUser, + teams: [{ teamId: mockedTeam.id, accepted: true, userId: mockUser.id }], +}; + +const userInTeamNotAccepted: UserWithMembership = { + ...mockUser, + teams: [{ teamId: mockedTeam.id, accepted: false, userId: mockUser.id }], +}; + describe("Invite Member Utils", () => { describe("checkPermissions", () => { it("It should throw an error if the user is not an admin of the ORG", async () => { @@ -134,20 +116,7 @@ describe("Invite Member Utils", () => { 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"; @@ -270,8 +239,8 @@ describe("Invite Member Utils", () => { }); }); - describe("throwIfInviteIsToOrgAndUserExists", () => { - const invitee: User = { + describe("validateInviteeEligibility: Check if user can be invited to the team/org", () => { + const invitee: Invitee = { ...mockUser, id: 1, username: "testuser", @@ -280,8 +249,8 @@ describe("Invite Member Utils", () => { }; const isOrg = false; - it("should not throw when inviting an existing user to the same organization", () => { - const inviteeWithOrg: User = { + it("should not throw when inviting to an organization's team an existing org user", () => { + const inviteeWithOrg: Invitee = { ...invitee, organizationId: 2, }; @@ -289,10 +258,36 @@ describe("Invite Member Utils", () => { ...mockedTeam, parentId: 2, }; - expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow(); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).not.toThrow(); }); + + it("should throw a TRPCError when inviting a user who is already a member of the org", () => { + const inviteeWithOrg: Invitee = { + ...invitee, + organizationId: 1, + }; + const teamWithOrg = { + ...mockedTeam, + id: 1, + }; + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + }); + + it("should throw a TRPCError when inviting a user who is already a member of the team", () => { + const inviteeWithOrg: UserWithMembership = { + ...invitee, + organizationId: null, + teams: [{ teamId: 1, accepted: true, userId: invitee.id }], + }; + const teamWithOrg = { + ...mockedTeam, + id: 1, + }; + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + }); + it("should throw a TRPCError with code FORBIDDEN if the invitee is already a member of another organization", () => { - const inviteeWithOrg: User = { + const inviteeWithOrg: Invitee = { ...invitee, organizationId: 2, }; @@ -300,36 +295,48 @@ describe("Invite Member Utils", () => { ...mockedTeam, parentId: 3, }; - expect(() => throwIfInviteIsToOrgAndUserExists(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(inviteeWithOrg, teamWithOrg, isOrg)).toThrow(TRPCError); }); it("should throw a TRPCError with code FORBIDDEN if the invitee already exists in Cal.com and is being invited to an organization", () => { const isOrg = true; - expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).toThrow(TRPCError); + expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).toThrow(TRPCError); }); it("should not throw an error if the invitee does not already belong to another organization and is not being invited to an organization", () => { - expect(() => throwIfInviteIsToOrgAndUserExists(invitee, mockedTeam, isOrg)).not.toThrow(); + expect(() => validateInviteeEligibility(invitee, mockedTeam, isOrg)).not.toThrow(); }); }); - describe("createAndAutoJoinIfInOrg", () => { + describe("shouldAutoJoinIfInOrg", () => { it("should return autoJoined: false if the user is not in the same organization as the team", async () => { - const result = await createAndAutoJoinIfInOrg({ + const result = await shouldAutoJoinIfInOrg({ team: mockedTeam, - role: MembershipRole.ADMIN, - invitee: mockUser, + invitee: userInTeamAccepted, }); - expect(result).toEqual({ autoJoined: false }); + expect(result).toEqual(false); }); it("should return autoJoined: false if the team does not have a parent organization", async () => { - const result = await createAndAutoJoinIfInOrg({ + const result = await shouldAutoJoinIfInOrg({ team: { ...mockedTeam, parentId: null }, - role: MembershipRole.ADMIN, - invitee: mockUser, + invitee: userInTeamAccepted, }); - expect(result).toEqual({ autoJoined: false }); + expect(result).toEqual(false); + }); + + it("should return `autoJoined: false` if team has parent organization and invitee has not accepted membership to organization", async () => { + const result = await shouldAutoJoinIfInOrg({ + team: { ...mockedTeam, parentId: mockedTeam.id }, + invitee: { ...userInTeamNotAccepted, organizationId: mockedTeam.id }, + }); + expect(result).toEqual(false); + }); + it("should return `autoJoined: true` if team has parent organization and invitee has accepted membership to organization", async () => { + const result = await shouldAutoJoinIfInOrg({ + team: { ...mockedTeam, parentId: mockedTeam.id }, + invitee: { ...userInTeamAccepted, organizationId: mockedTeam.id }, + }); + expect(result).toEqual(true); }); - // TODO: Add test for when the user is already a member of the organization - need to mock prisma response value }); }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index ddde7024d8..fb0a38f8ca 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -3,11 +3,12 @@ import type { TFunction } from "next-i18next"; import { sendTeamInviteEmail, sendOrganizationAutoJoinEmail } from "@calcom/emails"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; import { isTeamAdmin } from "@calcom/lib/server/queries"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import type { Team } from "@calcom/prisma/client"; +import type { Membership, Team } from "@calcom/prisma/client"; import { Prisma, type User } from "@calcom/prisma/client"; import type { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -18,6 +19,15 @@ import type { TrpcSessionUser } from "../../../../trpc"; import { isEmail } from "../util"; import type { InviteMemberOptions, TeamWithParent } from "./types"; +export type Invitee = Pick< + User, + "id" | "email" | "organizationId" | "username" | "password" | "identityProvider" | "completedOnboarding" +>; + +export type UserWithMembership = Invitee & { + teams?: Pick[]; +}; + export async function checkPermissions({ userId, teamId, @@ -53,7 +63,9 @@ export async function getTeamOrThrow(teamId: number, isOrg?: boolean) { } export async function getEmailsToInvite(usernameOrEmail: string | string[]) { - const emailsToInvite = Array.isArray(usernameOrEmail) ? usernameOrEmail : [usernameOrEmail]; + const emailsToInvite = Array.isArray(usernameOrEmail) + ? Array.from(new Set(usernameOrEmail)) + : [usernameOrEmail]; if (emailsToInvite.length === 0) { throw new TRPCError({ @@ -65,43 +77,102 @@ export async function getEmailsToInvite(usernameOrEmail: string | string[]) { return emailsToInvite; } -export async function getUserToInviteOrThrowIfExists({ - usernameOrEmail, - teamId, - isOrg, -}: { - usernameOrEmail: string; - teamId: number; - isOrg?: boolean; -}) { - // Check if user exists in ORG or exists all together - - const orgWhere = isOrg && { - organizationId: teamId, - }; - const invitee = await prisma.user.findFirst({ - where: { - OR: [{ username: usernameOrEmail, ...orgWhere }, { email: usernameOrEmail }], - }, - }); - - // We throw on error cause we can't have two users in the same org with the same username - if (isOrg && invitee) { +export function validateInviteeEligibility( + invitee: UserWithMembership, + team: TeamWithParent, + isOrg: boolean +) { + const alreadyInvited = invitee.teams?.find(({ teamId: membershipTeamId }) => team.id === membershipTeamId); + if (alreadyInvited) { throw new TRPCError({ - code: "NOT_FOUND", - message: `Email ${usernameOrEmail} already exists, you can't invite existing users.`, + code: "BAD_REQUEST", + message: `${invitee.email} has already been invited.`, }); } - return invitee; + const orgMembership = invitee.teams?.find((membersip) => membersip.teamId === team.parentId); + // invitee is invited to the org's team and is already part of the organization + if (invitee.organizationId && team.parentId && invitee.organizationId === team.parentId) { + return; + } + + // user invited to join a team inside an org, but has not accepted invite to org yet + if (team.parentId && orgMembership && !orgMembership.accepted) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `User ${invitee.username} needs to accept the invitation to join your organization first.`, + }); + } + + // user is invited to join a team which is not in his organization + if (invitee.organizationId && invitee.organizationId !== team.parentId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `User ${invitee.username} is already a member of another organization.`, + }); + } + + if (invitee && isOrg) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, + }); + } + + if (team.parentId && invitee) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `You cannot add a user that already exists in Cal.com to an organization's team. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, + }); + } } -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 getUsersToInvite({ + usernameOrEmail, + isInvitedToOrg, + team, +}: { + usernameOrEmail: string[]; + isInvitedToOrg: boolean; + team: TeamWithParent; +}): Promise { + const orgWhere = isInvitedToOrg && { + organizationId: team.id, + }; + const memberships = []; + if (isInvitedToOrg) { + memberships.push({ teamId: team.id }); + } else { + memberships.push({ teamId: team.id }); + team.parentId && memberships.push({ teamId: team.parentId }); + } + + const invitees: UserWithMembership[] = await prisma.user.findMany({ + where: { + OR: [{ username: { in: usernameOrEmail }, ...orgWhere }, { email: { in: usernameOrEmail } }], + }, + select: { + id: true, + email: true, + organizationId: true, + username: true, + password: true, + completedOnboarding: true, + identityProvider: true, + teams: { + select: { teamId: true, userId: true, accepted: true }, + where: { + OR: memberships, + }, + }, + }, + }); + + // Check if the users found in the database can be invited to join the team/org + invitees.forEach((invitee) => { + validateInviteeEligibility(invitee, team, isInvitedToOrg); + }); + return invitees; } export function getOrgConnectionInfo({ @@ -133,84 +204,92 @@ export function getOrgConnectionInfo({ return { orgId, autoAccept }; } -export async function createNewUserConnectToOrgIfExists({ - usernameOrEmail, +export async function createNewUsersConnectToOrgIfExists({ + usernamesOrEmails, input, parentId, autoAcceptEmailDomain, - connectionInfo, + connectionInfoMap, }: { - usernameOrEmail: string; + usernamesOrEmails: string[]; input: InviteMemberOptions["input"]; parentId?: number | null; autoAcceptEmailDomain?: string; - connectionInfo: ReturnType; + connectionInfoMap: Record>; }) { - const { orgId, autoAccept } = connectionInfo; + await prisma.$transaction(async (tx) => { + for (let index = 0; index < usernamesOrEmails.length; index++) { + const usernameOrEmail = usernamesOrEmails[index]; + const { orgId, autoAccept } = connectionInfoMap[usernameOrEmail]; + const [emailUser, emailDomain] = usernameOrEmail.split("@"); + const username = + emailDomain === autoAcceptEmailDomain + ? slugify(emailUser) + : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - const [emailUser, emailDomain] = usernameOrEmail.split("@"); - const username = - emailDomain === autoAcceptEmailDomain - ? slugify(emailUser) - : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - - const createdUser = await prisma.user.create({ - data: { - username, - email: usernameOrEmail, - verified: true, - invitedTo: input.teamId, - organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org - teams: { - create: { - teamId: input.teamId, - role: input.role as MembershipRole, - accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted + const createdUser = await tx.user.create({ + data: { + username, + email: usernameOrEmail, + verified: true, + invitedTo: input.teamId, + organizationId: orgId || null, // If the user is invited to a child team, they are automatically added to the parent org + teams: { + create: { + teamId: input.teamId, + role: input.role as MembershipRole, + accepted: autoAccept, // If the user is invited to a child team, they are automatically accepted + }, + }, }, - }, - }, - }); + }); - // We also need to create the membership in the parent org if it exists - if (parentId) { - await prisma.membership.create({ - data: { - teamId: parentId, - userId: createdUser.id, - role: input.role as MembershipRole, - accepted: autoAccept, - }, - }); - } + // We also need to create the membership in the parent org if it exists + if (parentId) { + await tx.membership.create({ + data: { + teamId: parentId, + userId: createdUser.id, + role: input.role as MembershipRole, + accepted: autoAccept, + }, + }); + } + } + }); } -export async function createProvisionalMembership({ +export async function createProvisionalMemberships({ input, - invitee, + invitees, parentId, }: { input: InviteMemberOptions["input"]; - invitee: User; + invitees: UserWithMembership[]; parentId?: number; }) { try { - await prisma.membership.create({ - data: { - teamId: input.teamId, - userId: invitee.id, - role: input.role as MembershipRole, - }, - }); - // Create the membership in the parent also if it exists - if (parentId) { - await prisma.membership.create({ - data: { - teamId: parentId, + await prisma.membership.createMany({ + data: invitees.flatMap((invitee) => { + const data = []; + // membership for the team + data.push({ + teamId: input.teamId, userId: invitee.id, role: input.role as MembershipRole, - }, - }); - } + }); + + // membership for the org + if (parentId) { + data.push({ + teamId: parentId, + userId: invitee.id, + role: input.role as MembershipRole, + }); + } + return data; + }), + }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { // Don't throw an error if the user is already a member of the team when inviting multiple users @@ -219,9 +298,13 @@ export async function createProvisionalMembership({ code: "FORBIDDEN", message: "This user is a member of this team / has a pending invitation.", }); - } else { - console.log(`User ${invitee.id} is already a member of this team.`); + } else if (Array.isArray(input.usernameOrEmail) && e.code === "P2002") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Trying to invite users already members of this team / have pending invitations", + }); } + logger.error("Failed to create provisional memberships", input.teamId); } else throw e; } } @@ -282,26 +365,6 @@ export async function sendVerificationEmail({ } } -export function throwIfInviteIsToOrgAndUserExists(invitee: User, team: TeamWithParent, isOrg: boolean) { - if (invitee.organizationId && invitee.organizationId === team.parentId) { - return; - } - - if (invitee.organizationId && invitee.organizationId !== team.parentId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `User ${invitee.username} is already a member of another organization.`, - }); - } - - if ((invitee && isOrg) || (team.parentId && invitee)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `You cannot add a user that already exists in Cal.com to an organization. If they wish to join via this email address, they must update their email address in their profile to that of your organization.`, - }); - } -} - export function getIsOrgVerified( isOrg: boolean, team: Team & { @@ -331,52 +394,125 @@ export function getIsOrgVerified( } as { isInOrgScope: false; orgVerified: never; autoAcceptEmailDomain: never }; } -export async function createAndAutoJoinIfInOrg({ +export function shouldAutoJoinIfInOrg({ team, - role, invitee, }: { team: TeamWithParent; - invitee: User; - role: MembershipRole; + invitee: UserWithMembership; }) { + // Not a member of the org if (invitee.organizationId && invitee.organizationId !== team.parentId) { - return { - autoJoined: false, - }; + return false; } - + // team is an Org if (!team.parentId) { - return { - autoJoined: false, - }; + return false; } - const orgMembership = await prisma.membership.findFirst({ - where: { - userId: invitee.id, - teamId: team.parentId, - }, - }); + const orgMembership = invitee.teams?.find((membership) => membership.teamId === team.parentId); if (!orgMembership?.accepted) { - return { - autoJoined: false, - }; + return false; } - // Since we early return if the user is not a member of the org. Or the team they are being invited to is an org (not having a parentID) - // We create the membership in the child team - await prisma.membership.create({ - data: { - userId: invitee.id, - teamId: team.id, - accepted: true, - role: role, - }, + return true; +} +// split invited users between ones that can autojoin and the others who cannot autojoin +export const groupUsersByJoinability = ({ + existingUsersWithMembersips, + team, +}: { + team: TeamWithParent; + existingUsersWithMembersips: UserWithMembership[]; +}) => { + const usersToAutoJoin = []; + const regularUsers = []; + + for (let index = 0; index < existingUsersWithMembersips.length; index++) { + const existingUserWithMembersips = existingUsersWithMembersips[index]; + + const canAutojoin = shouldAutoJoinIfInOrg({ + invitee: existingUserWithMembersips, + team, + }); + + canAutojoin + ? usersToAutoJoin.push(existingUserWithMembersips) + : regularUsers.push(existingUserWithMembersips); + } + + return [usersToAutoJoin, regularUsers]; +}; + +export const sendEmails = async (emailPromises: Promise[]) => { + const sentEmails = await Promise.allSettled(emailPromises); + sentEmails.forEach((sentEmail) => { + if (sentEmail.status === "rejected") { + logger.error("Could not send email to user"); + } + }); +}; + +export const sendTeamInviteEmails = async ({ + existingUsersWithMembersips, + language, + currentUserTeamName, + currentUserName, + isOrg, + teamId, +}: { + language: TFunction; + existingUsersWithMembersips: UserWithMembership[]; + currentUserTeamName?: string; + currentUserName?: string | null; + isOrg: boolean; + teamId: number; +}) => { + const sendEmailsPromises = existingUsersWithMembersips.map(async (user) => { + let sendTo = user.email; + if (!isEmail(user.email)) { + sendTo = user.email; + } + // inform user of membership by email + if (currentUserName && currentUserTeamName) { + const inviteTeamOptions = { + joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, + isCalcomMember: true, + }; + /** + * Here we want to redirect to a different place if onboarding has been completed or not. This prevents the flash of going to teams -> Then to onboarding - also show a different email template. + * This only changes if the user is a CAL user and has not completed onboarding and has no password + */ + if (!user.completedOnboarding && !user.password && user.identityProvider === "CAL") { + const token = randomBytes(32).toString("hex"); + await prisma.verificationToken.create({ + data: { + identifier: user.email, + token, + expires: new Date(new Date().setHours(168)), // +1 week + team: { + connect: { + id: teamId, + }, + }, + }, + }); + + inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; + inviteTeamOptions.isCalcomMember = false; + } + + return sendTeamInviteEmail({ + language, + from: currentUserName, + to: sendTo, + teamName: currentUserTeamName, + ...inviteTeamOptions, + isOrg: isOrg, + }); + } }); - return { - autoJoined: true, - }; -} + await sendEmails(sendEmailsPromises); +}; From 4524d722f69784980f2c3ced57f4b6885d558910 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Mon, 27 Nov 2023 15:57:01 +0530 Subject: [PATCH 039/292] fix: phone-number-input (#12266) --- apps/web/playwright/fixtures/regularBookings.ts | 13 +++++++------ apps/web/public/static/locales/en/common.json | 1 + .../react-awesome-query-builder/widgets.tsx | 2 ++ packages/features/form-builder/Components.tsx | 12 +++++++++++- packages/features/form-builder/FormBuilderField.tsx | 6 +++++- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index b0a84078e0..93671b0a59 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -73,10 +73,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc }, multiselect: async () => { if (customLocators.shouldChangeMultiSelectLocator) { - await eventTypePage.locator("form svg").nth(1).click(); + await eventTypePage.getByLabel("multi-select-dropdown").click(); await eventTypePage.getByTestId("select-option-Option 1").click(); } else { - await eventTypePage.locator("form svg").last().click(); + await eventTypePage.getByLabel("multi-select-dropdown").last().click(); await eventTypePage.getByTestId("select-option-Option 1").click(); } }, @@ -88,10 +88,10 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc }, select: async () => { if (customLocators.shouldChangeSelectLocator) { - await eventTypePage.locator("form svg").nth(1).click(); + await eventTypePage.getByLabel("select-dropdown").first().click(); await eventTypePage.getByTestId("select-option-Option 1").click(); } else { - await eventTypePage.locator("form svg").last().click(); + await eventTypePage.getByLabel("select-dropdown").last().click(); await eventTypePage.getByTestId("select-option-Option 1").click(); } }, @@ -138,11 +138,12 @@ const fillAllQuestions = async (eventTypePage: Page, questions: string[], option await eventTypePage.getByPlaceholder("Textarea test").fill("This is a sample text for textarea."); break; case "select": - await eventTypePage.locator("form svg").last().click(); + await eventTypePage.getByLabel("select-dropdown").last().click(); await eventTypePage.getByTestId("select-option-Option 1").click(); break; case "multiselect": - await eventTypePage.locator("form svg").nth(4).click(); + // select-dropdown + await eventTypePage.getByLabel("multi-select-dropdown").click(); await eventTypePage.getByTestId("select-option-Option 1").click(); break; case "number": diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 378daac39d..a5ac5b14d5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2121,6 +2121,7 @@ "manage_availability_schedules":"Manage availability schedules", "lock_timezone_toggle_on_booking_page": "Lock timezone on booking page", "description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.", + "number_in_international_format": "Please enter number in international format.", "install_calendar":"Install Calendar", "branded_subdomain": "Branded Subdomain", "branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com", diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx index 4e018496f4..c62e1d0b0f 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx @@ -166,6 +166,7 @@ const MultiSelectWidget = ({ return ( { if (!item) { diff --git a/packages/features/form-builder/Components.tsx b/packages/features/form-builder/Components.tsx index da955e1cd9..b16723d0e9 100644 --- a/packages/features/form-builder/Components.tsx +++ b/packages/features/form-builder/Components.tsx @@ -20,6 +20,7 @@ import { CheckboxField, } from "@calcom/ui"; import { UserPlus, X } from "@calcom/ui/components/icon"; +import InfoBadge from "@calcom/web/components/ui/InfoBadge"; import { ComponentForField } from "./FormBuilderField"; import { propsTypes } from "./propsTypes"; @@ -395,6 +396,7 @@ export const Components: Record = { } }, [options, setValue, value]); + const { t } = useLocale(); return (
    @@ -418,17 +420,25 @@ export const Components: Record = { checked={value?.value === option.value} /> {option.label ?? ""} + + {option.value === "phone" && ( + + )} + ); }) ) : ( // Show option itself as label because there is just one option <> -