From 04cad3a69ec2b476a0a05745391174340b520178 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Fri, 22 Dec 2023 19:02:22 +0000 Subject: [PATCH 01/42] chore: remove scrollbar-gutter (#12936) --- apps/web/styles/globals.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 0a23ba25d6bcbc..01822234c2ee25 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -109,10 +109,6 @@ background: var(--cal-brand); } -html { - scrollbar-gutter: stable; -} - body  { text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; From c4792c55fe688d93c0432b1ffc665138e85495d8 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Fri, 22 Dec 2023 19:52:25 +0000 Subject: [PATCH 02/42] chore: minor changes to instant meetings (#12931) --- apps/web/components/eventtype/EventTypeSingleLayout.tsx | 3 ++- apps/web/components/eventtype/InstantEventController.tsx | 4 ++-- apps/web/pages/connect-and-join.tsx | 6 +++--- apps/web/public/static/locales/en/common.json | 2 +- packages/features/bookings/Booker/Booker.tsx | 6 +++--- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index fccd003fef956b..6fa3669abcd1bf 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -46,6 +46,7 @@ import { ExternalLink, Code, Trash, + PhoneCall, MoreHorizontal, Loader, } from "@calcom/ui/components/icon"; @@ -115,7 +116,7 @@ function getNavigation(props: { { name: "workflows", href: `/event-types/${eventType.id}?tabName=workflows`, - icon: Zap, + icon: PhoneCall, info: `${enabledWorkflowsNumber} ${t("active")}`, }, ]; diff --git a/apps/web/components/eventtype/InstantEventController.tsx b/apps/web/components/eventtype/InstantEventController.tsx index ec34df66328502..9a0b42ee2d928d 100644 --- a/apps/web/components/eventtype/InstantEventController.tsx +++ b/apps/web/components/eventtype/InstantEventController.tsx @@ -8,7 +8,7 @@ import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hook import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Alert, Button, EmptyScreen, SettingsToggle } from "@calcom/ui"; -import { Zap } from "@calcom/ui/components/icon"; +import { PhoneCall } from "@calcom/ui/components/icon"; type InstantEventControllerProps = { eventType: EventTypeSetup; @@ -44,7 +44,7 @@ export default function InstantEventController({ {!isOrg || !isTeamEvent ? ( {t("upgrade")}} /> diff --git a/apps/web/pages/connect-and-join.tsx b/apps/web/pages/connect-and-join.tsx index afc84f3e885630..8d85bb62fe9f04 100644 --- a/apps/web/pages/connect-and-join.tsx +++ b/apps/web/pages/connect-and-join.tsx @@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { TRPCClientError } from "@calcom/trpc/react"; import { Button, EmptyScreen, Alert } from "@calcom/ui"; -import { Zap } from "@calcom/ui/components/icon"; +import { PhoneCall } from "@calcom/ui/components/icon"; import PageWrapper from "@components/PageWrapper"; @@ -51,7 +51,7 @@ function ConnectAndJoin() { {session ? ( @@ -60,7 +60,7 @@ function ConnectAndJoin() { Some other host already accepted the meeting. Do you still want to join? - Continue to Meeting Url + Continue to Meeting diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 324e39cf2652f4..cbd28be4c99954 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -132,7 +132,7 @@ "meeting_url": "Meeting URL", "meeting_url_not_found":"Meeting URL not found", "token_not_found":"Token not found", - "some_other_host_already_accepted_the_meeting":"Some other host already accepted the meeting. Do you still want to join? <1>Continue to Meeting Url", + "some_other_host_already_accepted_the_meeting":"Some other host already accepted the meeting. Do you still want to join? <1>Continue to Meeting", "meeting_request_rejected": "Your meeting request has been rejected", "rejected_event_type_with_organizer": "Rejected: {{eventType}} with {{organizer}} on {{date}}", "hi": "Hi", diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 7ffbb1c9d16bc9..425a558f3df1b0 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -13,7 +13,7 @@ import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils"; -import { AvatarGroup, Button } from "@calcom/ui"; +import { Button } from "@calcom/ui"; import { AvailableTimeSlots } from "./components/AvailableTimeSlots"; import { BookEventForm } from "./components/BookEventForm"; @@ -392,7 +392,7 @@ export const InstantBooking = () => {
{/* TODO: max. show 4 people here */}
- { title: "Alex Van Andel", }, ]} - /> + /> */}
{t("dont_want_to_wait")}
From 55c9efec3edea7dc8016aa0fc087a4dc0a4e4a7d Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 23 Dec 2023 18:18:02 +0530 Subject: [PATCH 03/42] fix: Flakiness in tests (#12929) --- apps/web/playwright/booking-pages.e2e.ts | 5 ++- apps/web/playwright/fixtures/emails.ts | 34 +++++++++++++++++ apps/web/playwright/fixtures/users.ts | 37 ++++++++++++++++--- apps/web/playwright/lib/fixtures.ts | 16 ++------ apps/web/playwright/lib/testUtils.ts | 13 +++++-- apps/web/playwright/oauth-provider.e2e.ts | 8 ++-- .../organization/across-org/across-org.e2e.ts | 3 +- apps/web/playwright/organization/expects.ts | 7 ++-- .../organization/organization-creation.e2e.ts | 23 ++++++++---- .../organization-invitation.e2e.ts | 24 ++++++------ apps/web/playwright/signup.e2e.ts | 4 +- apps/web/playwright/team/expects.ts | 7 ++-- .../playwright/team/team-invitation.e2e.ts | 13 +++++-- 13 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 apps/web/playwright/fixtures/emails.ts diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index e072b99b6b1a7a..a4733ab2a4c040 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -29,7 +29,10 @@ test.describe("free user", () => { test("cannot book same slot multiple times", async ({ page, users, emails }) => { const [user] = users.get(); - const bookerObj = { email: `testEmail-${randomString(4)}@example.com`, name: "testBooker" }; + const bookerObj = { + email: users.trackEmail({ username: "testEmail", domain: "example.com" }), + name: "testBooker", + }; // Click first event type await page.click('[data-testid="event-type-link"]'); diff --git a/apps/web/playwright/fixtures/emails.ts b/apps/web/playwright/fixtures/emails.ts new file mode 100644 index 00000000000000..8d02dacb2fd2b3 --- /dev/null +++ b/apps/web/playwright/fixtures/emails.ts @@ -0,0 +1,34 @@ +import mailhog from "mailhog"; + +import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; + +const unimplemented = () => { + throw new Error("Mailhog is not enabled"); +}; + +const hasUUID = (query: string) => { + return /[a-zA-Z0-9]{22}/.test(query) || /[0-9a-f]{8}/.test(query); +}; +export const createEmailsFixture = () => { + if (IS_MAILHOG_ENABLED) { + const mailhogAPI = mailhog(); + return { + search: (query: string, kind?: string, start?: number, limit?: number) => { + if (kind === "from" || kind === "to") { + if (!hasUUID(query)) { + throw new Error( + `You should not use "from" or "to" queries without UUID in emails. Because mailhog maintains all the emails sent through tests, you should be able to uniquely identify the email among those. Found query: ${query}` + ); + } + } + return mailhogAPI.search.bind(mailhogAPI)(query, kind, start, limit); + }, + deleteMessage: mailhogAPI.deleteMessage.bind(mailhogAPI), + }; + } else { + return { + search: unimplemented, + deleteMessage: unimplemented, + }; + } +}; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index b5cd0fa9f8c81c..ace5d8b7dca7f6 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -1,8 +1,9 @@ import type { Page, WorkerInfo } from "@playwright/test"; import type Prisma from "@prisma/client"; +import type { Team } from "@prisma/client"; import { Prisma as PrismaType } from "@prisma/client"; import { hashSync as hash } from "bcryptjs"; -import type { API } from "mailhog"; +import { uuid } from "short-uuid"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; @@ -13,6 +14,7 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { Schedule } from "@calcom/types/schedule"; import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils"; +import type { createEmailsFixture } from "./emails"; import { TimeZoneEnum } from "./types"; // Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests. @@ -101,7 +103,7 @@ const createTeamAndAddUser = async ( ) => { const slug = `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}`; const data: PrismaType.TeamCreateInput = { - name: `user-id-${user.id}'s Team ${isOrg ? "Org" : "Team"}`, + name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`, }; data.metadata = { ...(isUnpublished ? { requestedSlug: slug } : {}), @@ -140,8 +142,17 @@ const createTeamAndAddUser = async ( }; // creates a user fixture instance and stores the collection -export const createUsersFixture = (page: Page, emails: API | undefined, workerInfo: WorkerInfo) => { - const store = { users: [], page } as { users: UserFixture[]; page: typeof page }; +export const createUsersFixture = ( + page: Page, + emails: ReturnType, + workerInfo: WorkerInfo +) => { + const store = { users: [], trackedEmails: [], page, teams: [] } as { + users: UserFixture[]; + trackedEmails: { email: string }[]; + page: typeof page; + teams: Team[]; + }; return { buildForSignup: (opts?: Pick) => { const uname = @@ -322,6 +333,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn }, workerInfo ); + store.teams.push(team); const teamEvent = await createTeamEventType(user, team, scenario); if (scenario.teammates) { // Create Teammate users @@ -379,6 +391,16 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn store.users.push(userFixture); return userFixture; }, + /** + * Use this method to get an email that can be automatically cleaned up from all the places in DB + */ + trackEmail: ({ username, domain }: { username: string; domain: string }) => { + const email = `${username}-${uuid().substring(0, 8)}@${domain}`; + store.trackedEmails.push({ + email, + }); + return email; + }, get: () => store.users, logout: async () => { await page.goto("/auth/logout"); @@ -387,7 +409,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn const ids = store.users.map((u) => u.id); if (emails) { const emailMessageIds: string[] = []; - for (const user of store.users) { + for (const user of store.trackedEmails.concat(store.users.map((u) => ({ email: u.email })))) { const emailMessages = await emails.search(user.email); if (emailMessages && emailMessages.count > 0) { emailMessages.items.forEach((item) => { @@ -401,7 +423,12 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn } await prisma.user.deleteMany({ where: { id: { in: ids } } }); + // Delete all users that were tracked by email(if they were created) + await prisma.user.deleteMany({ where: { email: { in: store.trackedEmails.map((e) => e.email) } } }); + await prisma.team.deleteMany({ where: { id: { in: store.teams.map((org) => org.id) } } }); store.users = []; + store.teams = []; + store.trackedEmails = []; }, delete: async (id: number) => { await prisma.user.delete({ where: { id } }); diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 0d7f34879ada84..1b7cb5b3c04682 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -1,14 +1,11 @@ import type { Page } from "@playwright/test"; import { test as base } from "@playwright/test"; -import type { API } from "mailhog"; -import mailhog from "mailhog"; -import { IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; +import { createEmailsFixture } from "../fixtures/emails"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createFeatureFixture } from "../fixtures/features"; import { createOrgsFixture } from "../fixtures/orgs"; @@ -27,7 +24,7 @@ export interface Fixtures { embeds: ReturnType; servers: ReturnType; prisma: typeof prisma; - emails?: API; + emails: ReturnType; routingForms: ReturnType; bookingPage: ReturnType; features: ReturnType; @@ -84,14 +81,7 @@ export const test = base.extend({ await use(createRoutingFormsFixture()); }, emails: async ({}, use) => { - if (IS_MAILHOG_ENABLED) { - const mailhogAPI = mailhog(); - await use(mailhogAPI); - } else { - //FIXME: Ideally we should error out here. If someone is running tests with mailhog disabled, they should be aware of it - logger.warn("Mailhog is not enabled - Skipping Emails verification"); - await use(undefined); - } + await use(createEmailsFixture()); }, bookingPage: async ({ page }, use) => { const bookingPage = createBookingPageFixture(page); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index e22d94551ea001..2de5bca29f4c6a 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -6,13 +6,14 @@ import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; // eslint-disable-next-line no-restricted-imports import { noop } from "lodash"; -import type { API, Messages } from "mailhog"; +import type { Messages } from "mailhog"; import { totp } from "otplib"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; import type { IntervalLimit } from "@calcom/types/Calendar"; +import type { createEmailsFixture } from "../fixtures/emails"; import type { Fixtures } from "./fixtures"; import { test } from "./fixtures"; @@ -218,11 +219,15 @@ export async function getEmailsReceivedByUser({ emails, userEmail, }: { - emails?: API; + emails?: ReturnType; userEmail: string; }): Promise { if (!emails) return null; - return emails.search(userEmail, "to"); + const matchingEmails = await emails.search(userEmail, "to"); + if (!matchingEmails?.total) { + console.log(`No emails received by ${userEmail}`); + } + return matchingEmails; } export async function expectEmailsToHaveSubject({ @@ -231,7 +236,7 @@ export async function expectEmailsToHaveSubject({ booker, eventTitle, }: { - emails?: API; + emails?: ReturnType; organizer: { name?: string | null; email: string }; booker: { name: string; email: string }; eventTitle: string; diff --git a/apps/web/playwright/oauth-provider.e2e.ts b/apps/web/playwright/oauth-provider.e2e.ts index 701cabae9283fa..fae75e5cb74189 100644 --- a/apps/web/playwright/oauth-provider.e2e.ts +++ b/apps/web/playwright/oauth-provider.e2e.ts @@ -104,7 +104,7 @@ test.describe("OAuth Provider", () => { expect(meData.username.startsWith("test user")).toBe(true); }); - test("should create valid access toke & refresh token for team", async ({ page, users }) => { + test("should create valid access token & refresh token for team", async ({ page, users }) => { const user = await users.create({ username: "test user", name: "test user" }, { hasTeam: true }); await user.apiLogin(); @@ -157,8 +157,8 @@ test.describe("OAuth Provider", () => { const meData = await meResponse.json(); - // check if team access token is valid - expect(meData.username.endsWith("Team Team")).toBe(true); + // Check if team access token is valid + expect(meData.username).toEqual(`user-id-${user.id}'s Team`); // request new token with refresh token const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, { @@ -186,7 +186,7 @@ test.describe("OAuth Provider", () => { }, }); - expect(meData.username.endsWith("Team Team")).toBe(true); + expect(meData.username).toEqual(`user-id-${user.id}'s Team`); }); test("redirect not logged-in users to login page and after forward to authorization page", async ({ diff --git a/apps/web/playwright/organization/across-org/across-org.e2e.ts b/apps/web/playwright/organization/across-org/across-org.e2e.ts index 14012d4da6cf40..ccd1c7309e010c 100644 --- a/apps/web/playwright/organization/across-org/across-org.e2e.ts +++ b/apps/web/playwright/organization/across-org/across-org.e2e.ts @@ -5,9 +5,8 @@ import prisma from "@calcom/prisma"; import { test } from "../../lib/fixtures"; -test.afterAll(({ users, emails }) => { +test.afterAll(({ users }) => { users.deleteAll(); - emails?.deleteAll(); }); test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => { diff --git a/apps/web/playwright/organization/expects.ts b/apps/web/playwright/organization/expects.ts index e5ba1a0e83acb6..901e3064409742 100644 --- a/apps/web/playwright/organization/expects.ts +++ b/apps/web/playwright/organization/expects.ts @@ -2,13 +2,14 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { JSDOM } from "jsdom"; // eslint-disable-next-line no-restricted-imports -import type { API, Messages } from "mailhog"; +import type { Messages } from "mailhog"; +import type { createEmailsFixture } from "playwright/fixtures/emails"; import { getEmailsReceivedByUser } from "../lib/testUtils"; export async function expectInvitationEmailToBeReceived( page: Page, - emails: API | undefined, + emails: ReturnType, userEmail: string, subject: string, returnLink?: string @@ -16,7 +17,7 @@ export async function expectInvitationEmailToBeReceived( if (!emails) return null; // We need to wait for the email to go through, otherwise it will fail // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(5000); + await page.waitForTimeout(2000); const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); expect(receivedEmails?.total).toBe(1); const [firstReceivedEmail] = (receivedEmails as Messages).items; diff --git a/apps/web/playwright/organization/organization-creation.e2e.ts b/apps/web/playwright/organization/organization-creation.e2e.ts index 35e49fe637ebf6..65c3b735a2618a 100644 --- a/apps/web/playwright/organization/organization-creation.e2e.ts +++ b/apps/web/playwright/organization/organization-creation.e2e.ts @@ -1,13 +1,14 @@ import { expect } from "@playwright/test"; import path from "path"; +import { uuid } from "short-uuid"; import { test } from "../lib/fixtures"; import { generateTotpCode } from "../lib/testUtils"; import { expectInvitationEmailToBeReceived } from "./expects"; -test.afterAll(({ users, emails }) => { +test.afterAll(({ users, orgs }) => { users.deleteAll(); - emails?.deleteAll(); + orgs.deleteAll(); }); function capitalize(text: string) { @@ -26,6 +27,11 @@ test.describe("Organization", () => { const orgOwner = await users.create({ role: "ADMIN", }); + const instanceAdmin = await users.create({ + username: `admin-${uuid()}`, + email: users.trackEmail({ username: "admin", domain: "example.com" }), + role: "ADMIN", + }); const orgDomain = `${orgOwner.username}-org`; const orgName = capitalize(`${orgOwner.username}-org`); await orgOwner.apiLogin(); @@ -38,7 +44,8 @@ test.describe("Organization", () => { await expect(page.locator(".text-red-700")).toHaveCount(3); // Happy path - await page.locator("input[name=adminEmail]").fill(`john@${orgDomain}.com`); + const adminEmail = users.trackEmail({ username: "john", domain: `${orgDomain}.com` }); + await page.locator("input[name=adminEmail]").fill(adminEmail); expect(await page.locator("input[name=name]").inputValue()).toEqual(orgName); expect(await page.locator("input[name=slug]").inputValue()).toEqual(orgDomain); await page.locator("button[type=submit]").click(); @@ -48,7 +55,7 @@ test.describe("Organization", () => { await expectInvitationEmailToBeReceived( page, emails, - `john@${orgOwner.username}-org.com`, + adminEmail, "Verify your email to create an organization" ); @@ -56,12 +63,11 @@ test.describe("Organization", () => { // Code verification await expect(page.locator("#modal-title")).toBeVisible(); await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`)); - // Check admin email about DNS pending action await expectInvitationEmailToBeReceived( page, emails, - "admin@example.com", + instanceAdmin.email, "New organization created: pending action" ); @@ -105,14 +111,15 @@ test.describe("Organization", () => { await page.locator("button[type=submit]").click(); // Happy path - await page.locator('textarea[name="emails"]').fill(`rick@${orgDomain}.com`); + const adminEmail = users.trackEmail({ username: "rick", domain: `${orgDomain}.com` }); + await page.locator('textarea[name="emails"]').fill(adminEmail); await page.locator("button[type=submit]").click(); // Check if invited admin received the invitation email await expectInvitationEmailToBeReceived( page, emails, - `rick@${orgDomain}.com`, + adminEmail, `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com` ); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 62340e9e3899bb..48e97214091898 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -9,12 +9,12 @@ import { expectInvitationEmailToBeReceived } from "./expects"; test.describe.configure({ mode: "parallel" }); -test.afterEach(async ({ users, emails }) => { +test.afterEach(async ({ users, orgs }) => { await users.deleteAll(); - emails?.deleteAll(); + await orgs.deleteAll(); }); -test.describe.serial("Organization", () => { +test.describe("Organization", () => { test.describe("Email not matching orgAutoAcceptEmail", () => { test("Org Invitation", async ({ browser, page, users, emails }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); @@ -24,9 +24,10 @@ test.describe.serial("Organization", () => { await page.waitForLoadState("networkidle"); await test.step("By email", async () => { - const invitedUserEmail = `rick-${Date.now()}@domain.com`; + const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" }); // '-domain' because the email doesn't match orgAutoAcceptEmail const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`; + await inviteAnEmail(page, invitedUserEmail); const inviteLink = await expectInvitationEmailToBeReceived( page, @@ -66,7 +67,7 @@ test.describe.serial("Organization", () => { await test.step("By invite link", async () => { const inviteLink = await copyInviteLink(page); - const email = `rick-${Date.now()}@domain.com`; + const email = users.trackEmail({ username: "rick", domain: "domain.com" }); // '-domain' because the email doesn't match orgAutoAcceptEmail const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`; await signupFromInviteLink({ browser, inviteLink, email }); @@ -91,7 +92,7 @@ test.describe.serial("Organization", () => { await test.step("By email", async () => { await page.goto(`/settings/teams/${team.id}/members`); await page.waitForLoadState("networkidle"); - const invitedUserEmail = `rick-${Date.now()}@domain.com`; + const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" }); // '-domain' because the email doesn't match orgAutoAcceptEmail const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`; await inviteAnEmail(page, invitedUserEmail); @@ -154,7 +155,7 @@ test.describe.serial("Organization", () => { await test.step("By invite link", async () => { await page.goto(`/settings/teams/${team.id}/members`); const inviteLink = await copyInviteLink(page); - const email = `rick-${Date.now()}@domain.com`; + const email = users.trackEmail({ username: "rick", domain: "domain.com" }); // '-domain' because the email doesn't match orgAutoAcceptEmail const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`; await signupFromInviteLink({ browser, inviteLink, email }); @@ -190,7 +191,7 @@ test.describe.serial("Organization", () => { await page.waitForLoadState("networkidle"); await test.step("By email", async () => { - const invitedUserEmail = `rick-${Date.now()}@example.com`; + const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" }); const usernameDerivedFromEmail = invitedUserEmail.split("@")[0]; await inviteAnEmail(page, invitedUserEmail); const inviteLink = await expectInvitationEmailToBeReceived( @@ -231,7 +232,7 @@ test.describe.serial("Organization", () => { await test.step("By invite link", async () => { const inviteLink = await copyInviteLink(page); - const email = `rick-${Date.now()}@example.com`; + const email = users.trackEmail({ username: "rick", domain: "example.com" }); const usernameDerivedFromEmail = email.split("@")[0]; await signupFromInviteLink({ browser, inviteLink, email }); @@ -262,7 +263,7 @@ test.describe.serial("Organization", () => { await test.step("By email", async () => { await page.goto(`/settings/teams/${team.id}/members`); await page.waitForLoadState("networkidle"); - const invitedUserEmail = `rick-${Date.now()}@example.com`; + const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" }); const usernameDerivedFromEmail = invitedUserEmail.split("@")[0]; await inviteAnEmail(page, invitedUserEmail); await expectUserToBeAMemberOfTeam({ @@ -323,7 +324,7 @@ test.describe.serial("Organization", () => { await page.goto(`/settings/teams/${team.id}/members`); const inviteLink = await copyInviteLink(page); - const email = `rick-${Date.now()}@example.com`; + const email = users.trackEmail({ username: "rick", domain: "example.com" }); // '-domain' because the email doesn't match orgAutoAcceptEmail const usernameDerivedFromEmail = `${email.split("@")[0]}`; @@ -460,6 +461,7 @@ async function expectUserToBeAMemberOfTeam({ }) { // Check newly invited member is not pending anymore await page.goto(`/settings/teams/${teamId}/members`); + await page.reload(); expect( ( await page.locator(`[data-testid="member-${username}"] [data-testid=member-role]`).textContent() diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 23ba84e14fe1ad..4e4c18fd379cb9 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -14,9 +14,8 @@ test.describe("Signup Flow Test", async () => { test.beforeEach(async ({ features }) => { features.reset(); // This resets to the inital state not an empt yarray }); - test.afterAll(async ({ users, emails }) => { + test.afterAll(async ({ users }) => { await users.deleteAll(); - emails?.deleteAll(); }); test("Username is taken", async ({ page, users }) => { // log in trail user @@ -204,6 +203,7 @@ test.describe("Signup Flow Test", async () => { data: { enabled: true }, }); const userToCreate = users.buildForSignup({ + email: users.trackEmail({ username: "email-verify", domain: "example.com" }), username: "email-verify", password: "Password99!", }); diff --git a/apps/web/playwright/team/expects.ts b/apps/web/playwright/team/expects.ts index 43e02063f65f08..4579a1e6137999 100644 --- a/apps/web/playwright/team/expects.ts +++ b/apps/web/playwright/team/expects.ts @@ -1,13 +1,14 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { JSDOM } from "jsdom"; -import type { API, Messages } from "mailhog"; +import type { Messages } from "mailhog"; +import type { createEmailsFixture } from "playwright/fixtures/emails"; import { getEmailsReceivedByUser } from "../lib/testUtils"; export async function expectInvitationEmailToBeReceived( page: Page, - emails: API | undefined, + emails: ReturnType, userEmail: string, subject: string, returnLink?: string @@ -15,7 +16,7 @@ export async function expectInvitationEmailToBeReceived( if (!emails) return null; // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(10000); + await page.waitForTimeout(2000); const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail }); expect(receivedEmails?.total).toBe(1); diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts index 31dd24080119b3..d5c5c77a3d8624 100644 --- a/apps/web/playwright/team/team-invitation.e2e.ts +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -8,9 +8,8 @@ import { expectInvitationEmailToBeReceived } from "./expects"; test.describe.configure({ mode: "parallel" }); -test.afterEach(async ({ users, emails }) => { +test.afterEach(async ({ users }) => { await users.deleteAll(); - emails?.deleteAll(); }); test.describe("Team", () => { @@ -23,7 +22,10 @@ test.describe("Team", () => { await page.waitForLoadState("networkidle"); await test.step("To the team by email (external user)", async () => { - const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`; + const invitedUserEmail = users.trackEmail({ + username: "rick", + domain: `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(); @@ -104,7 +106,10 @@ test.describe("Team", () => { await page.waitForLoadState("networkidle"); await test.step("To the organization by email (internal user)", async () => { - const invitedUserEmail = `rick@example.com`; + const invitedUserEmail = users.trackEmail({ + username: "rick", + domain: `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(); From 412e7ecbce1b1f291ac7e23cb1c1cd7cc8306b67 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 27 Dec 2023 16:16:17 +0000 Subject: [PATCH 04/42] fixes to org email (#12937) --- apps/web/public/static/locales/en/common.json | 6 +++--- packages/emails/src/templates/TeamInviteEmail.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index cbd28be4c99954..64b0c28f0ae8cd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1750,9 +1750,9 @@ "email_no_user_invite_heading_subteam": "You’ve been invited to join a team of {{parentTeamName}} organization", "email_no_user_invite_heading_org": "You’ve been invited to join a {{appName}} organization", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_user_invite_subheading_team": "{{invitedBy}} has invited you to join their team `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_user_invite_subheading_subteam": "{{invitedBy}} has invited you to join the team `{{teamName}}` in their organization {{parentTeamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email_user_invite_subheading_org": "{{invitedBy}} has invited you to join their organization `{{teamName}}` on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your organization to schedule meetings without the email tennis.", + "email_user_invite_subheading_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_user_invite_subheading_subteam": "{{invitedBy}} has invited you to join the team {{teamName}} in their organization {{parentTeamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", + "email_user_invite_subheading_org": "{{invitedBy}} has invited you to join their organization {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your organization to schedule meetings without the email tennis.", "email_no_user_invite_steps_intro": "We’ll walk you through a few short steps and you’ll be enjoying stress free scheduling with your {{entity}} in no time.", "email_no_user_step_one": "Choose your username", "email_no_user_step_two": "Connect your calendar account", diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index 6742fb5e7376c4..6be38be840cffb 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -65,7 +65,7 @@ export const TeamInviteEmail = ( {props.language( `email_user_invite_subheading_${props.isOrg ? "org" : props.parentTeamName ? "subteam" : "team"}`, { - invitedBy: props.from, + invitedBy: props.from.toString(), appName: APP_NAME, teamName: props.teamName, parentTeamName: props.parentTeamName, From 5de77e386caed28df1cba45f886febdeb583230d Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 29 Dec 2023 07:32:48 +1100 Subject: [PATCH 05/42] fix:Remove fixed height and overflow (#12959) --- packages/features/calendars/weeklyview/components/Calendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx index a3aad425e8846e..ba33adc2a67a2a 100644 --- a/packages/features/calendars/weeklyview/components/Calendar.tsx +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -66,7 +66,7 @@ export function Calendar(props: CalendarComponentProps) { style={{ width: "165%" }} className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full"> -
+
Date: Fri, 29 Dec 2023 15:25:26 +0530 Subject: [PATCH 06/42] feat: pipedrive crm app on cal (#12316) * add pipedrive crm app w/ revert api * update lockfile * fix issues highlighted by codacy * get pipedrive `client_id` & `client_secret` from db * update readme with instructions to add credentials * Fix yarn.lock * fix `turbo.json` --------- Co-authored-by: Hariom --- .env.appStore.example | 8 + README.md | 4 + .../app-store/apps.keys-schemas.generated.ts | 2 + packages/app-store/apps.metadata.generated.ts | 2 + packages/app-store/apps.schemas.generated.ts | 2 + packages/app-store/apps.server.generated.ts | 1 + packages/app-store/index.ts | 1 + .../app-store/pipedrive-crm/DESCRIPTION.md | 6 + packages/app-store/pipedrive-crm/README.md | 19 + packages/app-store/pipedrive-crm/api/add.ts | 35 + .../app-store/pipedrive-crm/api/callback.ts | 19 + packages/app-store/pipedrive-crm/api/index.ts | 2 + packages/app-store/pipedrive-crm/config.json | 17 + packages/app-store/pipedrive-crm/index.ts | 2 + .../pipedrive-crm/lib/CalendarService.ts | 313 +++++ packages/app-store/pipedrive-crm/lib/index.ts | 1 + packages/app-store/pipedrive-crm/package.json | 14 + .../app-store/pipedrive-crm/static/icon.svg | 23 + .../static/pipedrive-banner.jpeg | Bin 0 -> 65706 bytes packages/app-store/pipedrive-crm/zod.ts | 8 + turbo.json | 3 + yarn.lock | 1093 +++++++---------- 22 files changed, 944 insertions(+), 631 deletions(-) create mode 100644 packages/app-store/pipedrive-crm/DESCRIPTION.md create mode 100644 packages/app-store/pipedrive-crm/README.md create mode 100644 packages/app-store/pipedrive-crm/api/add.ts create mode 100644 packages/app-store/pipedrive-crm/api/callback.ts create mode 100644 packages/app-store/pipedrive-crm/api/index.ts create mode 100644 packages/app-store/pipedrive-crm/config.json create mode 100644 packages/app-store/pipedrive-crm/index.ts create mode 100644 packages/app-store/pipedrive-crm/lib/CalendarService.ts create mode 100644 packages/app-store/pipedrive-crm/lib/index.ts create mode 100644 packages/app-store/pipedrive-crm/package.json create mode 100644 packages/app-store/pipedrive-crm/static/icon.svg create mode 100644 packages/app-store/pipedrive-crm/static/pipedrive-banner.jpeg create mode 100644 packages/app-store/pipedrive-crm/zod.ts diff --git a/.env.appStore.example b/.env.appStore.example index 1b51680e41d107..15f9fa42fb56d9 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -127,4 +127,12 @@ ZOHOCRM_CLIENT_ID="" ZOHOCRM_CLIENT_SECRET="" +# - REVERT +# Used for the Pipedrive integration (via/ Revert (https://revert.dev)) +# @see https://github.com/calcom/cal.com/#obtaining-revert-api-keys +REVERT_API_KEY= +REVERT_PUBLIC_TOKEN= + +# NOTE: If you're self hosting Revert, update this URL to point to your own instance. +REVERT_API_URL=https://api.revert.dev/ # ********************************************************************************************************* diff --git a/README.md b/README.md index aafcd729755403..2092be150a95d0 100644 --- a/README.md +++ b/README.md @@ -554,6 +554,10 @@ following [Follow these steps](./packages/app-store/zoho-bigin/) +### Obtaining Pipedrive Client ID and Secret + +[Follow these steps](./packages/app-store/pipedrive-crm/) + ## Workflows ### Setting up SendGrid for Email reminders diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 0d8f1c1f24f814..541bb55bd30d9a 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -20,6 +20,7 @@ import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; import { appKeysSchema as paypal_zod_ts } from "./paypal/zod"; +import { appKeysSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; import { appKeysSchema as plausible_zod_ts } from "./plausible/zod"; import { appKeysSchema as qr_code_zod_ts } from "./qr_code/zod"; import { appKeysSchema as routing_forms_zod_ts } from "./routing-forms/zod"; @@ -57,6 +58,7 @@ export const appKeysSchemas = { office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, + "pipedrive-crm": pipedrive_crm_zod_ts, plausible: plausible_zod_ts, qr_code: qr_code_zod_ts, "routing-forms": routing_forms_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index cb252d45f88b88..e2c9f3e9780c1e 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -42,6 +42,7 @@ import office365video_config_json from "./office365video/config.json"; import paypal_config_json from "./paypal/config.json"; import ping_config_json from "./ping/config.json"; import pipedream_config_json from "./pipedream/config.json"; +import pipedrive_crm_config_json from "./pipedrive-crm/config.json"; import plausible_config_json from "./plausible/config.json"; import qr_code_config_json from "./qr_code/config.json"; import raycast_config_json from "./raycast/config.json"; @@ -120,6 +121,7 @@ export const appStoreMetadata = { paypal: paypal_config_json, ping: ping_config_json, pipedream: pipedream_config_json, + "pipedrive-crm": pipedrive_crm_config_json, plausible: plausible_config_json, qr_code: qr_code_config_json, raycast: raycast_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 505f91ed99365a..3e1e37634ec5b7 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -20,6 +20,7 @@ import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; import { appDataSchema as paypal_zod_ts } from "./paypal/zod"; +import { appDataSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; import { appDataSchema as plausible_zod_ts } from "./plausible/zod"; import { appDataSchema as qr_code_zod_ts } from "./qr_code/zod"; import { appDataSchema as routing_forms_zod_ts } from "./routing-forms/zod"; @@ -57,6 +58,7 @@ export const appDataSchemas = { office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, + "pipedrive-crm": pipedrive_crm_zod_ts, plausible: plausible_zod_ts, qr_code: qr_code_zod_ts, "routing-forms": routing_forms_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index aa68d01ec14bba..55d544efdcf704 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -42,6 +42,7 @@ export const apiHandlers = { paypal: import("./paypal/api"), ping: import("./ping/api"), pipedream: import("./pipedream/api"), + "pipedrive-crm": import("./pipedrive-crm/api"), plausible: import("./plausible/api"), qr_code: import("./qr_code/api"), raycast: import("./raycast/api"), diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 2a90955403a842..940e6449946c84 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -16,6 +16,7 @@ const appStore = { office365video: () => import("./office365video"), plausible: () => import("./plausible"), paypal: () => import("./paypal"), + "pipedrive-crm": () => import("./pipedrive-crm"), salesforce: () => import("./salesforce"), zohocrm: () => import("./zohocrm"), sendgrid: () => import("./sendgrid"), diff --git a/packages/app-store/pipedrive-crm/DESCRIPTION.md b/packages/app-store/pipedrive-crm/DESCRIPTION.md new file mode 100644 index 00000000000000..1ace85dd67fd4b --- /dev/null +++ b/packages/app-store/pipedrive-crm/DESCRIPTION.md @@ -0,0 +1,6 @@ +--- +items: +- pipedrive-banner.jpeg +--- + +{DESCRIPTION} diff --git a/packages/app-store/pipedrive-crm/README.md b/packages/app-store/pipedrive-crm/README.md new file mode 100644 index 00000000000000..ac053cab044420 --- /dev/null +++ b/packages/app-store/pipedrive-crm/README.md @@ -0,0 +1,19 @@ +## Pipedrive Integration via Revert + +#### Obtaining Pipedrive Client ID and Secret + +* Open [Pipedrive Developers Corner](https://developers.pipedrive.com/) and sign in to your account, or create a new one +* Go to Settings > (company name) Developer Hub +* Create a Pipedrive app, using the steps mentioned [here](https://pipedrive.readme.io/docs/marketplace-creating-a-proper-app#create-an-app-in-5-simple-steps) + * You can skip this step and use the default revert Pipedrive app +* Set `https://app.revert.dev/oauth-callback/pipedrive` as a callback url for your app +* **Get your client\_id and client\_secret**: + * Go to the "OAuth & access scopes" tab of your app + * Copy your client\_id and client\_secret + +#### Obtaining Revert API keys + +* Create an account on Revert if you don't already have one. (https://app.revert.dev/sign-up) +* Login to your revert dashboard (https://app.revert.dev/sign-in) and click on `Customize your apps` - `Pipedrive` +* Enter the `client_id` and `client_secret` you copied in the previous step +* Enter the `client_id` and `client_secret` previously copied to `Settings > Admin > Apps > CRM > Pipedrive` by clicking the `Edit` button on the app settings. diff --git a/packages/app-store/pipedrive-crm/api/add.ts b/packages/app-store/pipedrive-crm/api/add.ts new file mode 100644 index 00000000000000..a5e06acf013f3f --- /dev/null +++ b/packages/app-store/pipedrive-crm/api/add.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { createDefaultInstallation } from "@calcom/app-store/_utils/installation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { HttpError } from "@calcom/lib/http-error"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import appConfig from "../config.json"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" }); + const appKeys = await getAppKeysFromSlug(appConfig.slug); + let client_id = ""; + if (typeof appKeys.client_id === "string") client_id = appKeys.client_id; + if (!client_id) return res.status(400).json({ message: "pipedrive client id missing." }); + // Check that user is authenticated + req.session = await getServerSession({ req, res }); + const { teamId } = req.query; + const userId = req.session?.user.id; + if (!userId) { + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); + } + await createDefaultInstallation({ + appType: `${appConfig.slug}_other_calendar`, + userId: userId, + slug: appConfig.slug, + key: {}, + teamId: Number(teamId), + }); + const tenantId = teamId ? teamId : userId; + res.status(200).json({ + url: `https://oauth.pipedrive.com/oauth/authorize?client_id=${appKeys.client_id}&redirect_uri=https://app.revert.dev/oauth-callback/pipedrive&state={%22tenantId%22:%22${tenantId}%22,%22revertPublicToken%22:%22${process.env.REVERT_PUBLIC_TOKEN}%22}`, + newTab: true, + }); +} diff --git a/packages/app-store/pipedrive-crm/api/callback.ts b/packages/app-store/pipedrive-crm/api/callback.ts new file mode 100644 index 00000000000000..46e143332842f2 --- /dev/null +++ b/packages/app-store/pipedrive-crm/api/callback.ts @@ -0,0 +1,19 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; + +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import appConfig from "../config.json"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + + const state = decodeOAuthState(req); + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }) + ); +} diff --git a/packages/app-store/pipedrive-crm/api/index.ts b/packages/app-store/pipedrive-crm/api/index.ts new file mode 100644 index 00000000000000..eb12c1b4ed2c4f --- /dev/null +++ b/packages/app-store/pipedrive-crm/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/pipedrive-crm/config.json b/packages/app-store/pipedrive-crm/config.json new file mode 100644 index 00000000000000..ff390101bab855 --- /dev/null +++ b/packages/app-store/pipedrive-crm/config.json @@ -0,0 +1,17 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Pipedrive CRM", + "slug": "pipedrive-crm", + "type": "pipedrive-crm_other_calendar", + "logo": "icon.svg", + "url": "https://revert.dev", + "variant": "crm", + "categories": ["crm"], + "publisher": "Revert.dev ", + "email": "jatin@revert.dev", + "description": "Founded in 2010, Pipedrive is an easy and effective sales CRM that drives small business growth.\r\rToday, Pipedrive is used by revenue teams at more than 100,000 companies worldwide. Pipedrive is headquartered in New York and has offices across Europe and the US.\r\rThe company is backed by majority holder Vista Equity Partners, Bessemer Venture Partners, Insight Partners, Atomico, and DTCP.\r\rLearn more at www.pipedrive.com.", + "isTemplate": false, + "__createdUsingCli": true, + "__template": "basic", + "dirName": "pipedrive-crm" +} diff --git a/packages/app-store/pipedrive-crm/index.ts b/packages/app-store/pipedrive-crm/index.ts new file mode 100644 index 00000000000000..e2e9d7b029c031 --- /dev/null +++ b/packages/app-store/pipedrive-crm/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/pipedrive-crm/lib/CalendarService.ts b/packages/app-store/pipedrive-crm/lib/CalendarService.ts new file mode 100644 index 00000000000000..a3c80cd0a720cb --- /dev/null +++ b/packages/app-store/pipedrive-crm/lib/CalendarService.ts @@ -0,0 +1,313 @@ +import { getLocation } from "@calcom/lib/CalEventParser"; +import logger from "@calcom/lib/logger"; +import type { + Calendar, + CalendarEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, + Person, +} from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; + +import appConfig from "../config.json"; + +type ContactSearchResult = { + status: string; + results: Array<{ + id: string; + email: string; + firstName: string; + lastName: string; + name: string; + }>; +}; + +type ContactCreateResult = { + status: string; + result: { + id: string; + email: string; + firstName: string; + lastName: string; + name: string; + }; +}; + +export default class PipedriveCalendarService implements Calendar { + private log: typeof logger; + private tenantId: string; + private revertApiKey: string; + private revertApiUrl: string; + constructor(credential: CredentialPayload) { + this.revertApiKey = process.env.REVERT_API_KEY || ""; + this.revertApiUrl = process.env.REVERT_API_URL || "https://api.revert.dev/"; + this.tenantId = String(credential.teamId ? credential.teamId : credential.userId); // Question: Is this a reasonable assumption to be made? Get confirmation on the exact field to be used here. + this.log = logger.getSubLogger({ prefix: [`[[lib] ${appConfig.slug}`] }); + } + + private createContacts = async (attendees: Person[]) => { + const result = attendees.map(async (attendee) => { + const headers = new Headers(); + headers.append("x-revert-api-token", this.revertApiKey); + headers.append("x-revert-t-id", this.tenantId); + headers.append("Content-Type", "application/json"); + + const [firstname, lastname] = !!attendee.name ? attendee.name.split(" ") : [attendee.email, "-"]; + const bodyRaw = JSON.stringify({ + firstName: firstname, + lastName: lastname || "-", + email: attendee.email, + }); + + const requestOptions = { + method: "POST", + headers: headers, + body: bodyRaw, + }; + + try { + const response = await fetch(`${this.revertApiUrl}crm/contacts`, requestOptions); + const result = (await response.json()) as ContactCreateResult; + return result; + } catch (error) { + return Promise.reject(error); + } + }); + return await Promise.all(result); + }; + + private contactSearch = async (event: CalendarEvent) => { + const result = event.attendees.map(async (attendee) => { + const headers = new Headers(); + headers.append("x-revert-api-token", this.revertApiKey); + headers.append("x-revert-t-id", this.tenantId); + headers.append("Content-Type", "application/json"); + + const bodyRaw = JSON.stringify({ searchCriteria: attendee.email }); + + const requestOptions = { + method: "POST", + headers: headers, + body: bodyRaw, + }; + + try { + const response = await fetch(`${this.revertApiUrl}crm/contacts/search`, requestOptions); + const result = (await response.json()) as ContactSearchResult; + return result; + } catch (error) { + return { status: "error", results: [] }; + } + }); + return await Promise.all(result); + }; + + private getMeetingBody = (event: CalendarEvent): string => { + return `${event.organizer.language.translate("invitee_timezone")}: ${ + event.attendees[0].timeZone + }

${event.organizer.language.translate("share_additional_notes")}
${ + event.additionalNotes || "-" + }`; + }; + + private createPipedriveEvent = async (event: CalendarEvent, contacts: CalendarEvent["attendees"]) => { + const eventPayload = { + subject: event.title, + startDateTime: event.startTime, + endDateTime: event.endTime, + description: this.getMeetingBody(event), + location: getLocation(event), + associations: { + contactId: String(contacts[0].id), + }, + }; + const headers = new Headers(); + headers.append("x-revert-api-token", this.revertApiKey); + headers.append("x-revert-t-id", this.tenantId); + headers.append("Content-Type", "application/json"); + + const eventBody = JSON.stringify(eventPayload); + const requestOptions = { + method: "POST", + headers: headers, + body: eventBody, + }; + + return await fetch(`${this.revertApiUrl}crm/events`, requestOptions); + }; + + private updateMeeting = async (uid: string, event: CalendarEvent) => { + const eventPayload = { + subject: event.title, + startDateTime: event.startTime, + endDateTime: event.endTime, + description: this.getMeetingBody(event), + location: getLocation(event), + }; + const headers = new Headers(); + headers.append("x-revert-api-token", this.revertApiKey); + headers.append("x-revert-t-id", this.tenantId); + headers.append("Content-Type", "application/json"); + + const eventBody = JSON.stringify(eventPayload); + const requestOptions = { + method: "PATCH", + headers: headers, + body: eventBody, + }; + + return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions); + }; + + private deleteMeeting = async (uid: string) => { + const headers = new Headers(); + headers.append("x-revert-api-token", this.revertApiKey); + headers.append("x-revert-t-id", this.tenantId); + + const requestOptions = { + method: "DELETE", + headers: headers, + }; + + return await fetch(`${this.revertApiUrl}crm/events/${uid}`, requestOptions); + }; + + async handleEventCreation(event: CalendarEvent, contacts: CalendarEvent["attendees"]) { + const meetingEvent = await (await this.createPipedriveEvent(event, contacts)).json(); + if (meetingEvent && meetingEvent.status === "ok") { + this.log.debug("event:creation:ok", { meetingEvent }); + return Promise.resolve({ + uid: meetingEvent.result.id, + id: meetingEvent.result.id, + type: appConfig.slug, + password: "", + url: "", + additionalInfo: { contacts, meetingEvent }, + }); + } + this.log.debug("meeting:creation:notOk", { meetingEvent, event, contacts }); + return Promise.reject("Something went wrong when creating a meeting in PipedriveCRM"); + } + + async createEvent(event: CalendarEvent): Promise { + let contacts = await this.contactSearch(event); + contacts = contacts.filter((c) => c.results.length >= 1); + if (contacts && contacts.length) { + if (contacts.length === event.attendees.length) { + // all contacts are in Pipedrive CRM already. + this.log.debug("contact:search:all", { event, contacts: contacts }); + const existingPeople = contacts.map((c) => { + return { + id: Number(c.results[0].id), + name: `${c.results[0].firstName} ${c.results[0].lastName}`, + email: c.results[0].email, + timeZone: event.attendees[0].timeZone, + language: event.attendees[0].language, + }; + }); + return await this.handleEventCreation(event, existingPeople); + } else { + // Some attendees don't exist in PipedriveCRM + // Get the existing contacts' email to filter out + this.log.debug("contact:search:notAll", { event, contacts }); + const existingContacts = contacts.map((contact) => contact.results[0].email); + this.log.debug("contact:filter:existing", { existingContacts }); + // Get non existing contacts filtering out existing from attendees + const nonExistingContacts: Person[] = event.attendees.filter( + (attendee) => !existingContacts.includes(attendee.email) + ); + this.log.debug("contact:filter:nonExisting", { nonExistingContacts }); + // Only create contacts in PipedriveCRM that were not present in the previous contact search + const createdContacts = await this.createContacts(nonExistingContacts); + this.log.debug("contact:created", { createdContacts }); + // Continue with event creation and association only when all contacts are present in Pipedrive + if (createdContacts[0] && createdContacts[0].status === "ok") { + this.log.debug("contact:creation:ok"); + const existingPeople = contacts.map((c) => { + return { + id: Number(c.results[0].id), + name: c.results[0].name, + email: c.results[0].email, + timeZone: nonExistingContacts[0].timeZone, + language: nonExistingContacts[0].language, + }; + }); + const newlyCreatedPeople = createdContacts.map((c) => { + return { + id: Number(c.result.id), + name: c.result.name, + email: c.result.email, + timeZone: nonExistingContacts[0].timeZone, + language: nonExistingContacts[0].language, + }; + }); + const allContacts = existingPeople.concat(newlyCreatedPeople); + // ensure the order of attendees is maintained. + allContacts.sort((a, b) => { + const indexA = event.attendees.findIndex((c) => c.email === a.email); + const indexB = event.attendees.findIndex((c) => c.email === b.email); + return indexA - indexB; + }); + return await this.handleEventCreation(event, allContacts); + } + return Promise.reject({ + calError: "Something went wrong when creating non-existing attendees in PipedriveCRM", + }); + } + } else { + this.log.debug("contact:search:none", { event, contacts }); + const createdContacts = await this.createContacts(event.attendees); + this.log.debug("contact:created", { createdContacts }); + if (createdContacts[0] && createdContacts[0].status === "ok") { + this.log.debug("contact:creation:ok"); + const newContacts = createdContacts.map((c) => { + return { + id: Number(c.result.id), + name: c.result.name, + email: c.result.email, + timeZone: event.attendees[0].timeZone, + language: event.attendees[0].language, + }; + }); + return await this.handleEventCreation(event, newContacts); + } + } + return Promise.reject({ + calError: "Something went wrong when searching/creating the attendees in PipedriveCRM", + }); + } + + async updateEvent(uid: string, event: CalendarEvent): Promise { + const meetingEvent = await (await this.updateMeeting(uid, event)).json(); + if (meetingEvent && meetingEvent.status === "ok") { + this.log.debug("event:updation:ok", { meetingEvent }); + return Promise.resolve({ + uid: meetingEvent.result.id, + id: meetingEvent.result.id, + type: appConfig.slug, + password: "", + url: "", + additionalInfo: { meetingEvent }, + }); + } + this.log.debug("meeting:updation:notOk", { meetingEvent, event }); + return Promise.reject("Something went wrong when updating a meeting in PipedriveCRM"); + } + + async deleteEvent(uid: string): Promise { + await this.deleteMeeting(uid); + } + + async getAvailability( + _dateFrom: string, + _dateTo: string, + _selectedCalendars: IntegrationCalendar[] + ): Promise { + return Promise.resolve([]); + } + + async listCalendars(_event?: CalendarEvent): Promise { + return Promise.resolve([]); + } +} diff --git a/packages/app-store/pipedrive-crm/lib/index.ts b/packages/app-store/pipedrive-crm/lib/index.ts new file mode 100644 index 00000000000000..e168c149df8531 --- /dev/null +++ b/packages/app-store/pipedrive-crm/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/pipedrive-crm/package.json b/packages/app-store/pipedrive-crm/package.json new file mode 100644 index 00000000000000..5d8130acf03222 --- /dev/null +++ b/packages/app-store/pipedrive-crm/package.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/pipedrive-crm", + "version": "0.0.0", + "main": "./index.ts", + "dependencies": { + "@calcom/lib": "*" + }, + "devDependencies": { + "@calcom/types": "*" + }, + "description": "Founded in 2010, Pipedrive is an easy and effective sales CRM that drives small business growth.\r\rToday, Pipedrive is used by revenue teams at more than 100,000 companies worldwide. Pipedrive is headquartered in New York and has offices across Europe and the US.\r\rThe company is backed by majority holder Vista Equity Partners, Bessemer Venture Partners, Insight Partners, Atomico, and DTCP.\r\rLearn more at www.pipedrive.com." +} diff --git a/packages/app-store/pipedrive-crm/static/icon.svg b/packages/app-store/pipedrive-crm/static/icon.svg new file mode 100644 index 00000000000000..689c4cd3374f24 --- /dev/null +++ b/packages/app-store/pipedrive-crm/static/icon.svg @@ -0,0 +1,23 @@ + + + + Pipedrive_letter_logo_light@1,5x + + + Created with Sketch. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app-store/pipedrive-crm/static/pipedrive-banner.jpeg b/packages/app-store/pipedrive-crm/static/pipedrive-banner.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..0d07a67795d191e5d5be5693dd1a87a9a4b2b8d9 GIT binary patch literal 65706 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.11 <=16" - checksum: 7b622944823fa12a77ea490656121a77e1a1daf08114a6a0b027922113f4481d95f4fe380a5de369a51657ef777d35757dc31f63e41071c21f3e97ca47e4205a + checksum: c5bfdeb6d06f528e2222e71bf830b2f4f3e5b95419453d3b650cef9fe012e0126f121e4858d950edf3db1fb209a056b592643751624d1bc1fc71ecbe546d53d5 languageName: node linkType: hard @@ -22375,9 +22210,9 @@ __metadata: linkType: hard "gsap@npm:^3.11.0": - version: 3.12.2 - resolution: "gsap@npm:3.12.2" - checksum: e3e9709f9ca60cee0ef7fd91f45a232a48b550f95b6df090dfffe2511fccc651408476de575b69c1c2ce80b6089b6ad0d4854beb58ea427f14e51a30b0640a70 + version: 3.12.4 + resolution: "gsap@npm:3.12.4" + checksum: 7b78fb46b3250c09a1856da32109bae16091be41bd3ec3a17ce2b6dea444fbecd2c57b9c677a09c6b4c022d3024e9ae39d377fab5f63302417a88a6b43231381 languageName: node linkType: hard @@ -22949,14 +22784,7 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.0.0": - version: 4.1.0 - resolution: "http-cache-semantics@npm:4.1.0" - checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 - languageName: node - linkType: hard - -"http-cache-semantics@npm:^4.1.0": +"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236 @@ -23280,9 +23108,9 @@ __metadata: linkType: hard "iframe-resizer@npm:^4.3.0": - version: 4.3.7 - resolution: "iframe-resizer@npm:4.3.7" - checksum: 252f459d0350223f3db6ec01649f79c51088653cf2d8edf932542fe92fab79b2a968403cc685af253ea168097f102dbc5ac8c579ece772e05be83fa39a6026bc + version: 4.3.9 + resolution: "iframe-resizer@npm:4.3.9" + checksum: 8f40a6e9bf27bf60682d9a80f12c60d96a3b724f5e9ef5bdbcf5f2ac58b1b17f6924e34cfc54210a0a065d305380e77d79e28c551f54bd3b1dac1da2624a2144 languageName: node linkType: hard @@ -23303,9 +23131,9 @@ __metadata: linkType: hard "ignore@npm:^5.2.4": - version: 5.2.4 - resolution: "ignore@npm:5.2.4" - checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef + version: 5.3.0 + resolution: "ignore@npm:5.3.0" + checksum: 2736da6621f14ced652785cb05d86301a66d70248597537176612bd0c8630893564bd5f6421f8806b09e8472e75c591ef01672ab8059c07c6eb2c09cefe04bf9 languageName: node linkType: hard @@ -24434,7 +24262,7 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:5.0.0, isomorphic-ws@npm:^5.0.0": +"isomorphic-ws@npm:^5.0.0": version: 5.0.0 resolution: "isomorphic-ws@npm:5.0.0" peerDependencies: @@ -24689,7 +24517,16 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^1.17.1, jiti@npm:^1.18.2": +"jiti@npm:^1.17.1": + version: 1.21.0 + resolution: "jiti@npm:1.21.0" + bin: + jiti: bin/jiti.js + checksum: a7bd5d63921c170eaec91eecd686388181c7828e1fa0657ab374b9372bfc1f383cf4b039e6b272383d5cb25607509880af814a39abdff967322459cca41f2961 + languageName: node + linkType: hard + +"jiti@npm:^1.18.2": version: 1.20.0 resolution: "jiti@npm:1.20.0" bin: @@ -24698,16 +24535,16 @@ __metadata: languageName: node linkType: hard -"joi@npm:^17.7.0": - version: 17.10.2 - resolution: "joi@npm:17.10.2" +"joi@npm:^17.11.0": + version: 17.11.0 + resolution: "joi@npm:17.11.0" dependencies: "@hapi/hoek": ^9.0.0 "@hapi/topo": ^5.0.0 "@sideway/address": ^4.1.3 "@sideway/formula": ^3.0.1 "@sideway/pinpoint": ^2.0.0 - checksum: 2fac59e83b35465d04ffcd33a937c39795264bdd3d392bebee8034710f84631a400cd320a3bb0bb736e70ce930abb1ea551bc3ffbeca023b53417d864eb216a4 + checksum: 3a4e9ecba345cdafe585e7ed8270a44b39718e11dff3749aa27e0001a63d578b75100c062be28e6f48f960b594864034e7a13833f33fbd7ad56d5ce6b617f9bf languageName: node linkType: hard @@ -24739,6 +24576,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.0.0": + version: 5.1.3 + resolution: "jose@npm:5.1.3" + checksum: c0225c3408b1c3fcfc40c68161e5e14d554c606ffa428250e0db618469df52f4ce32c69918e296c8c299db1081981638096be838426f61d926269d35602fd814 + languageName: node + linkType: hard + "jpeg-js@npm:0.4.2": version: 0.4.2 resolution: "jpeg-js@npm:0.4.2" @@ -24957,7 +24801,7 @@ __metadata: languageName: node linkType: hard -"json-buffer@npm:3.0.1, json-buffer@npm:~3.0.1": +"json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581 @@ -25030,11 +24874,14 @@ __metadata: linkType: hard "json-stable-stringify@npm:^1.0.1": - version: 1.0.2 - resolution: "json-stable-stringify@npm:1.0.2" + version: 1.1.0 + resolution: "json-stable-stringify@npm:1.1.0" dependencies: + call-bind: ^1.0.5 + isarray: ^2.0.5 jsonify: ^0.0.1 - checksum: ec10863493fb728481ed7576551382768a173d5b884758db530def00523b862083a3fd70fee24b39e2f47f5f502e22f9a1489dd66da3535b63bf6241dbfca800 + object-keys: ^1.1.1 + checksum: 98e74dd45d3e93aa7cb5351b9f55475e15a8a7b57f401897373a1a1bbe41a6757f8b8d24f2bff0594893eccde616efe71bbaea2c1fdc1f67e8c39bcb9ee993e2 languageName: node linkType: hard @@ -25288,12 +25135,11 @@ __metadata: linkType: hard "keyv@npm:^4.0.0": - version: 4.3.3 - resolution: "keyv@npm:4.3.3" + version: 4.5.4 + resolution: "keyv@npm:4.5.4" dependencies: - compress-brotli: ^1.3.8 json-buffer: 3.0.1 - checksum: bcc946eeec3407fb3b42d831ce985357162113c5f07a8c45c12ede39704ba2d99be4c3dded76d2d2d2a2366627e42440bdde24393216164156928399949c12a1 + checksum: 74a24395b1c34bd44ad5cb2b49140d087553e170625240b86755a6604cd65aa16efdbdeae5cdb17ba1284a0fbb25ad06263755dbc71b8d8b06f74232ce3cdd72 languageName: node linkType: hard @@ -27824,7 +27670,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.1.0, minimist@npm:^1.2.3, minimist@npm:^1.2.7": +"minimist@npm:^1.1.0, minimist@npm:^1.2.3, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 @@ -30483,11 +30329,11 @@ __metadata: linkType: hard "playwright-core@npm:^1.38.1": - version: 1.38.1 - resolution: "playwright-core@npm:1.38.1" + version: 1.40.1 + resolution: "playwright-core@npm:1.40.1" bin: playwright-core: cli.js - checksum: 66e83fe040f309b13ad94ba39dea40ac207bfcbbc22de13141af88dbdedd64e1c4e3ce1d0cb070d4efd8050d7e579953ec3681dd8a0acf2c1cc738d9c50e545e + checksum: 84d92fb9b86e3c225b16b6886bf858eb5059b4e60fa1205ff23336e56a06dcb2eac62650992dede72f406c8e70a7b6a5303e511f9b4bc0b85022ede356a01ee0 languageName: node linkType: hard @@ -31172,9 +31018,9 @@ __metadata: linkType: hard "property-information@npm:^6.0.0": - version: 6.3.0 - resolution: "property-information@npm:6.3.0" - checksum: bf0a15dec097fd4324a42163cabd96b90819e48ef0d8d98756ef0420b2c579bf33646fe0b6e04aa9e79f5a2b5b5860ef11655a79cd8969d8eda58df23c4f96c9 + version: 6.4.0 + resolution: "property-information@npm:6.4.0" + checksum: b5aed9a40e87730995f3ceed29839f137fa73b2a4cccfb8ed72ab8bddb8881cad05c3487c4aa168d7cb49a53db8089790c9f00f59d15b8380d2bb5383cdd1f24 languageName: node linkType: hard @@ -31314,7 +31160,7 @@ __metadata: languageName: node linkType: hard -"pvtsutils@npm:^1.3.2": +"pvtsutils@npm:^1.3.2, pvtsutils@npm:^1.3.5": version: 1.3.5 resolution: "pvtsutils@npm:1.3.5" dependencies: @@ -31818,12 +31664,12 @@ __metadata: linkType: hard "react-fast-marquee@npm:^1.3.5": - version: 1.6.1 - resolution: "react-fast-marquee@npm:1.6.1" + version: 1.6.2 + resolution: "react-fast-marquee@npm:1.6.2" peerDependencies: react: ">= 16.8.0 || 18.0.0" react-dom: ">= 16.8.0 || 18.0.0" - checksum: 35cb639d516ed87d950757ac80ca1ef84b63b64be8400802514225b4566870ead21a6cddcfe1bdd805724a0e173718dde4a9a4162efbabbfee6b6adc490a6eab + checksum: 5bdd2115f042a734c97317f45237450116665db999ed6fc63326c6b9f34dd93a06ea601c7a9a8157960b5da22fcd3516e75fe28ecb296631b24de94f22ad1efc languageName: node linkType: hard @@ -33029,9 +32875,9 @@ __metadata: linkType: hard "remeda@npm:^1.24.1": - version: 1.27.0 - resolution: "remeda@npm:1.27.0" - checksum: 0298aba3ade1d5694bf5a8ca592c735f5ed17325d1f1ee8e4bfc5d80384de47cf6cc42093167f8c512dc4e453896f0ac59685598222328545d7c047c990120a4 + version: 1.33.0 + resolution: "remeda@npm:1.33.0" + checksum: 3d07b191e10b67bb9a008646b98031c12bd20caba25f280a7f491de4c9db7e10150f1243f831669586bb65c3829dfe3319e08c1a5a90414b3fd040c83f2034bf languageName: node linkType: hard @@ -33578,7 +33424,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.0.0, rxjs@npm:^7.8.0": +"rxjs@npm:^7.0.0, rxjs@npm:^7.8.1": version: 7.8.1 resolution: "rxjs@npm:7.8.1" dependencies: @@ -35212,11 +35058,11 @@ __metadata: linkType: hard "superjson@npm:^1.9.1": - version: 1.13.1 - resolution: "superjson@npm:1.13.1" + version: 1.13.3 + resolution: "superjson@npm:1.13.3" dependencies: copy-anything: ^3.0.2 - checksum: 9c8c664a924ce097250112428805ccc8b500018b31a91042e953d955108b8481c156005d836b413940c9fa5f124a3195f55f3a518fe76510a254a59f9151a204 + checksum: f5aeb010f24163cb871a4bc402d9164112201c059afc247a75b03131c274aea6eec9cf08be9e4a9465fe4961689009a011584528531d52f7cc91c077e07e5c75 languageName: node linkType: hard @@ -36344,7 +36190,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2, tslib@npm:^2.4.1, tslib@npm:^2.6.1": +"tslib@npm:^2, tslib@npm:^2.4.1, tslib@npm:^2.6.1, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad @@ -36817,9 +36663,9 @@ __metadata: linkType: hard "ua-parser-js@npm:^1.0.33, ua-parser-js@npm:^1.0.35": - version: 1.0.36 - resolution: "ua-parser-js@npm:1.0.36" - checksum: 5b2c8a5e3443dfbba7624421805de946457c26ae167cb2275781a2729d1518f7067c9d5c74c3b0acac4b9ff3278cae4eace08ca6eecb63848bc3b2f6a63cc975 + version: 1.0.37 + resolution: "ua-parser-js@npm:1.0.37" + checksum: 4d481c720d523366d7762dc8a46a1b58967d979aacf786f9ceceb1cd767de069f64a4bdffb63956294f1c0696eb465ddb950f28ba90571709e33521b4bd75e07 languageName: node linkType: hard @@ -36885,11 +36731,11 @@ __metadata: linkType: hard "undici@npm:^5.12.0": - version: 5.25.2 - resolution: "undici@npm:5.25.2" + version: 5.28.2 + resolution: "undici@npm:5.28.2" dependencies: - busboy: ^1.6.0 - checksum: 1177a9c4fc9a1ddb765508d0f69ae61c166559badce8d797aaa92beef70ec5ac8fdc420b643f5d8d40b9a37891ba5536e2070d86a9c54a128aec67ad0c862d06 + "@fastify/busboy": ^2.0.0 + checksum: f9e9335803f962fff07c3c11c6d50bbc76248bacf97035047155adb29c3622a65bd6bff23a22218189740133149d22e63b68131d8c40e78ac6cb4b6d686a6dfa languageName: node linkType: hard @@ -37871,17 +37717,17 @@ __metadata: linkType: hard "wait-on@npm:^7.0.1": - version: 7.0.1 - resolution: "wait-on@npm:7.0.1" + version: 7.2.0 + resolution: "wait-on@npm:7.2.0" dependencies: - axios: ^0.27.2 - joi: ^17.7.0 + axios: ^1.6.1 + joi: ^17.11.0 lodash: ^4.17.21 - minimist: ^1.2.7 - rxjs: ^7.8.0 + minimist: ^1.2.8 + rxjs: ^7.8.1 bin: wait-on: bin/wait-on - checksum: 1e8a17d8ee6436f71d3ab82781ce31267481fcd7bbccde49b0f8124871e6e40a1acac3401f04f775ba6203853a5813352fa131620fc139914351f3b2894d573f + checksum: 69ec1432bb4479363fdd71f2f3f501a98aa356a562781108a4a89ef8fdf1e3d5fd0c2fd56c4cc5902abbb662065f1f22d4e436a1e6fc9331ce8b575eb023325e languageName: node linkType: hard @@ -38344,21 +38190,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:8.14.1": - version: 8.14.1 - resolution: "ws@npm:8.14.1" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 9e310be2b0ff69e1f87d8c6d093ecd17a1ed4c37f281d17c35e8c30e2bd116401775b3d503249651374e6e0e1e9905db62fff096b694371c77561aee06bc3466 - languageName: node - linkType: hard - "ws@npm:^6.1.0": version: 6.2.2 resolution: "ws@npm:6.2.2" @@ -38398,9 +38229,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.12.0": - version: 8.14.2 - resolution: "ws@npm:8.14.2" +"ws@npm:^8.12.0, ws@npm:^8.15.0": + version: 8.15.1 + resolution: "ws@npm:8.15.1" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -38409,7 +38240,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 3ca0dad26e8cc6515ff392b622a1467430814c463b3368b0258e33696b1d4bed7510bc7030f7b72838b9fdeb8dbd8839cbf808367d6aae2e1d668ce741d4308b + checksum: 8c67365f6e6134278ad635d558bfce466d7ef7543a043baea333aaa430429f0af8a130c0c36e7dd78f918d68167a659ba9b5067330b77c4b279e91533395952b languageName: node linkType: hard @@ -38683,9 +38514,9 @@ __metadata: linkType: hard "yaml@npm:^2.3.1": - version: 2.3.2 - resolution: "yaml@npm:2.3.2" - checksum: acd80cc24df12c808c6dec8a0176d404ef9e6f08ad8786f746ecc9d8974968c53c6e8a67fdfabcc5f99f3dc59b6bb0994b95646ff03d18e9b1dcd59eccc02146 + version: 2.3.4 + resolution: "yaml@npm:2.3.4" + checksum: e6d1dae1c6383bcc8ba11796eef3b8c02d5082911c6723efeeb5ba50fc8e881df18d645e64de68e421b577296000bea9c75d6d9097c2f6699da3ae0406c030d8 languageName: node linkType: hard From bdc36acdf5e378b817ca644fc6e44680b117a31f Mon Sep 17 00:00:00 2001 From: Saurabh Jaswal Date: Fri, 29 Dec 2023 23:03:41 +0530 Subject: [PATCH 07/42] fix: updated the bg-emphasis style in dark mode (#12933) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/styles/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index 01822234c2ee25..cdaf8a8cf60d28 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -51,7 +51,7 @@ .dark { /* background */ - --cal-bg-emphasis: hsla(0, 0%, 32%, 1); + --cal-bg-emphasis: hsla(0, 0%, 25%, 1); --cal-bg: hsla(0, 0%, 10%, 1); --cal-bg-subtle: hsla(0, 0%, 18%, 1); --cal-bg-muted: hsla(0, 0%, 12%, 1); From 16d1adf9908d15a956493b1cf7a73001e6a8108e Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Fri, 29 Dec 2023 18:08:36 +0000 Subject: [PATCH 08/42] fix: dark mode improvements for signup (#12965) * dark mode improvements * small changes * readded producthunt badges * only show social proof on cal.com --- apps/web/pages/signup.tsx | 125 ++++++++++++------ .../public/product-cards/trustpilot-dark.svg | 27 ++++ packages/lib/constants.ts | 2 + 3 files changed, 111 insertions(+), 43 deletions(-) create mode 100644 apps/web/public/product-cards/trustpilot-dark.svg diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index f7c558363e12dd..7eae7d4463c454 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -19,7 +19,14 @@ import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { useFlagMap } from "@calcom/features/flags/context/provider"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { classNames } from "@calcom/lib"; -import { APP_NAME, IS_CALCOM, IS_SELF_HOSTED, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { + APP_NAME, + URL_PROTOCOL_REGEX, + IS_CALCOM, + IS_SELF_HOSTED, + WEBAPP_URL, + WEBSITE_URL, +} from "@calcom/lib/constants"; import { fetchUsername } from "@calcom/lib/fetchUsername"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; @@ -253,7 +260,7 @@ export default function Signup({
{/* Left side */} -
+
{/* Header */} {errors.apiError && ( @@ -273,7 +280,7 @@ export default function Signup({ )}
{/* Form Container */} -
+
setPremiumUsername(value)} addOnLeading={ orgSlug - ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` - : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` + ? `${getOrgFullOrigin(orgSlug, { protocol: true }).replace(URL_PROTOCOL_REGEX, "")}/` + : `${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/` } /> ) : null} @@ -461,54 +468,86 @@ export default function Signup({
-
+
{IS_CALCOM && ( -
-
- # -
-
- # + <> +
+
+ Cal.com was Product of the Day at ProductHunt +
+
+ Cal.com was Product of the Week at ProductHunt +
+
+ Cal.com was Product of the Month at ProductHunt +
-
- # +
+
+ ProductHunt Rating of 5 Stars +
+
+ Trustpilot Rating of 4.7 Stars + Trustpilot Rating of 4.7 Stars +
+
+ G2 Rating of 4.7 Stars +
-
+ )}
- # + Cal.com Booking Page
-
- {!IS_CALCOM && - FEATURES.map((feature) => ( - <> -
-
- - {t(feature.title)} -
-
-

- {t( - feature.description, - feature.i18nOptions && { - ...feature.i18nOptions, - } - )} -

-
+
+ {FEATURES.map((feature) => ( + <> +
+
+ + {t(feature.title)}
- - ))} +
+

+ {t( + feature.description, + feature.i18nOptions && { + ...feature.i18nOptions, + } + )} +

+
+
+ + ))}
diff --git a/apps/web/public/product-cards/trustpilot-dark.svg b/apps/web/public/product-cards/trustpilot-dark.svg new file mode 100644 index 00000000000000..c03dbf63580c17 --- /dev/null +++ b/apps/web/public/product-cards/trustpilot-dark.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index c1fa5a63810273..acf6e4ce8c0912 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -124,3 +124,5 @@ export const IS_PREMIUM_USERNAME_ENABLED = // Max number of invites to join a team/org that can be sent at once export const MAX_NB_INVITES = 100; + +export const URL_PROTOCOL_REGEX = /(^\w+:|^)\/\//; From 7579417d7e61aa3d2a97ff2bd6b53831c89ca2cd Mon Sep 17 00:00:00 2001 From: DmytroHryshyn <125881252+DmytroHryshyn@users.noreply.github.com> Date: Fri, 29 Dec 2023 20:21:19 +0200 Subject: [PATCH 09/42] chore: allow disabling source map generation with env var (#12899) --- .env.example | 3 +++ apps/web/next.config.js | 2 ++ turbo.json | 1 + 3 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 799046ac3456d1..71357c2d7c91c9 100644 --- a/.env.example +++ b/.env.example @@ -322,3 +322,6 @@ APP_ROUTER_SETTINGS_TEAMS_ENABLED=0 APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0 APP_ROUTER_APPS_ENABLED=0 APP_ROUTER_VIDEO_ENABLED=0 + +# disable setry server source maps +SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 9ac7032c8cd7cf..58c928083f54e4 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -572,6 +572,8 @@ if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) { nextConfig["sentry"] = { autoInstrumentServerFunctions: true, hideSourceMaps: true, + // disable source map generation for the server code + disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN, }; plugins.push(withSentryConfig); diff --git a/turbo.json b/turbo.json index 149f8ae51962fd..b0ed2de782c596 100644 --- a/turbo.json +++ b/turbo.json @@ -318,6 +318,7 @@ "SENDGRID_API_KEY", "SENDGRID_EMAIL", "SENDGRID_SYNC_API_KEY", + "SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", From 6f942cfcacfc2a086f059dcc295a86c0bc38351d Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Sat, 30 Dec 2023 22:23:14 +0000 Subject: [PATCH 10/42] chore: removed isSignup for 404 page (#12967) * removed isSignup for 404 page * removed unused i18n * minor fix to go back button * nit --- apps/web/pages/404.tsx | 321 ++++++------------ apps/web/public/static/locales/ar/common.json | 2 - apps/web/public/static/locales/cs/common.json | 2 - apps/web/public/static/locales/da/common.json | 2 - apps/web/public/static/locales/de/common.json | 2 - apps/web/public/static/locales/en/common.json | 2 - apps/web/public/static/locales/es/common.json | 2 - apps/web/public/static/locales/fr/common.json | 2 - apps/web/public/static/locales/he/common.json | 2 - apps/web/public/static/locales/it/common.json | 2 - apps/web/public/static/locales/ja/common.json | 2 - apps/web/public/static/locales/ko/common.json | 2 - apps/web/public/static/locales/nl/common.json | 2 - apps/web/public/static/locales/no/common.json | 2 - apps/web/public/static/locales/pl/common.json | 2 - .../public/static/locales/pt-BR/common.json | 2 - apps/web/public/static/locales/pt/common.json | 2 - apps/web/public/static/locales/ro/common.json | 2 - apps/web/public/static/locales/ru/common.json | 2 - apps/web/public/static/locales/sr/common.json | 2 - apps/web/public/static/locales/sv/common.json | 2 - apps/web/public/static/locales/tr/common.json | 2 - apps/web/public/static/locales/uk/common.json | 2 - apps/web/public/static/locales/vi/common.json | 2 - .../public/static/locales/zh-CN/common.json | 2 - .../public/static/locales/zh-TW/common.json | 2 - 26 files changed, 105 insertions(+), 266 deletions(-) diff --git a/apps/web/pages/404.tsx b/apps/web/pages/404.tsx index 12cb57ac7d2784..2410f6c4d48a06 100644 --- a/apps/web/pages/404.tsx +++ b/apps/web/pages/404.tsx @@ -85,7 +85,7 @@ export default function Custom404() { const isSuccessPage = pathname?.startsWith("/booking"); const isSubpage = pathname?.includes("/", 2) || isSuccessPage; - const isSignup = pathname?.startsWith("/signup"); + /** * If we're on 404 and the route is insights it means it is disabled * TODO: Abstract this for all disabled features @@ -112,7 +112,7 @@ export default function Custom404() {
- + {t("or_go_back_home")} @@ -129,7 +129,7 @@ export default function Custom404() { return ( <>
- {isSignup && process.env.NEXT_PUBLIC_WEBAPP_URL !== "https://app.cal.com" ? ( -
-
-

- {t("missing_license")} -

-

- {t("signup_requires")} -

-

{t("signup_requires_description", { companyName: "Cal.com" })}

-
-
-

- {t("next_steps")} -

- - -
-
- {((!isSubpage && IS_CALCOM) || - currentPageType === pageType.ORG || - currentPageType === pageType.TEAM) && ( - - )} -

- {t("popular_pages")} -

- + )} +

+ {t("popular_pages")} +

+ -
- - {t("or_go_back_home")} - - -
-
- - )} + ))} +
  • + +
    + + + +
    +
    +

    + + +

    +

    {t("join_our_community")}

    +
    +
    +
    +
    +
  • + +
    + + {t("or_go_back_home")} + + +
    +
    diff --git a/apps/web/public/static/locales/ar/common.json b/apps/web/public/static/locales/ar/common.json index 5eb20d94a9bcfc..6478916b0e9264 100644 --- a/apps/web/public/static/locales/ar/common.json +++ b/apps/web/public/static/locales/ar/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "لتمكين هذه الميزة، احصل على مفتاح نشر في وحدة التحكم {{consoleUrl}} وأضفه إلى وحدة التحكم الخاصة بك. nv باسم CALCOM_LICENSE_KEY. إذا كان لدى فريقك بالفعل ترخيص، يرجى الاتصال بـ {{supportMail}} للحصول على المساعدة.", "enterprise_license_development": "يمكنك اختبار هذه الميزة في وضع التطوير. لاستخدام الإنتاج، يرجى مطالبة المسؤول بالذهاب إلى <2>/auth/setup لإدخال مفتاح الترخيص.", "missing_license": "الترخيص مفقود", - "signup_requires": "يلزم ترخيص تجاري", - "signup_requires_description": "لا تقدم شركة {{companyName}} حاليًا إصدارًا مجانيًا مفتوح المصدر لصفحة التسجيل. للحصول على حق الوصول الكامل إلى مكونات التسجيل، يجب أن تحصل على ترخيص تجاري. للاستخدام الشخصي، نوصي باستخدام منصة Prisma Data أو أي واجهة أخرى من واجهات Postgres لإنشاء الحسابات.", "next_steps": "الخطوات التالية", "acquire_commercial_license": "الحصول على ترخيص تجاري", "the_infrastructure_plan": "تعتمد خطة البنية التحتية على الاستخدام وتتضمن خصومات مناسبة للأعمال التجارية حديثة الإنشاء.", diff --git a/apps/web/public/static/locales/cs/common.json b/apps/web/public/static/locales/cs/common.json index a2a198fc3b5180..13f62236b3e68a 100644 --- a/apps/web/public/static/locales/cs/common.json +++ b/apps/web/public/static/locales/cs/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Pokud chcete povolit tuto funkci, získejte klíč pro nasazení v konzoli {{consoleUrl}} a přidejte ho do souboru .env jako CALCOM_LICENSE_KEY. Pokud váš tým již licenci má, kontaktujte prosím {{supportMail}} a požádejte o pomoc.", "enterprise_license_development": "Tuto funkci můžete vyzkoušet v režimu vývoje. Produkční použití vyžaduje od správce zadání licenčního klíče v <2>/auth/setup.", "missing_license": "Chybějící licence", - "signup_requires": "Je vyžadována komerční licence", - "signup_requires_description": "{{companyName}} v současné době nenabízí bezplatnou open source verzi stránky registrace. Chcete-li získat plný přístup ke komponentám registrace, musíte získat komerční licenci. Pro osobní použití doporučujeme Prisma Data Platform nebo jakékoli jiné Postgres rozhraní pro vytváření účtů.", "next_steps": "Další kroky", "acquire_commercial_license": "Získat komerční licenci", "the_infrastructure_plan": "Infrastruktura se účtuje podle reálného využívání a má příznivé slevy pro startupy.", diff --git a/apps/web/public/static/locales/da/common.json b/apps/web/public/static/locales/da/common.json index 2d36e88fbded79..0347e68017f5dc 100644 --- a/apps/web/public/static/locales/da/common.json +++ b/apps/web/public/static/locales/da/common.json @@ -801,8 +801,6 @@ "enterprise_license": "Dette er en virksomhedsfunktion", "enterprise_license_description": "For at aktivere denne funktion, skal du gå til {{setupUrl}} for at indtaste en licensnøgle. Hvis en licensnøgle allerede er på plads, bedes du kontakte {{supportMail}} for hjælp.", "missing_license": "Manglende Licens", - "signup_requires": "Erhvervsmæssig licens kræves", - "signup_requires_description": "{{companyName}} tilbyder i øjeblikket ikke en gratis open source version af tilmeldingssiden. For at modtage fuld adgang til tilmeldingskomponenterne skal du erhverve en kommerciel licens. Til personligt brug anbefaler vi Prisma Dataplatformen eller enhver anden Postgres-grænseflade for at oprette konti.", "next_steps": "Næste Trin", "acquire_commercial_license": "Erhverv en kommerciel licens", "the_infrastructure_plan": "Infrastrukturplanen er brugerbaseret og har startup-venlige rabatter.", diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 1b6af6f267ac6c..aafec106a21bf4 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Um diese Funktion zu aktivieren, holen Sie sich einen Deployment-Schlüssel von der {{consoleUrl}}-Konsole und fügen Sie ihn als CALCOM_LICENSE_KEY zu Ihrer .env hinzu. Wenn Ihr Team bereits eine Lizenz hat, wenden Sie sich bitte an {{supportMail}} für Hilfe.", "enterprise_license_development": "Sie können diese Funktion im Entwicklungsmodus testen. Zur Nutzung im regulären Arbeitsumfeld, besuchen Sie bitte <2>/auth/setup, um einen Lizenzschlüssel einzugeben.", "missing_license": "Lizenz fehlt", - "signup_requires": "Kommerzielle Lizenz erforderlich", - "signup_requires_description": "{{companyName}} bietet zur Zeit keine kostenlose Open-Source-Version der Anmeldeseite an. Um vollen Zugriff auf die Registrierungskomponenten zu erhalten, müssen Sie eine kommerzielle Lizenz erwerben. Für den persönlichen Gebrauch empfehlen wir die Prisma Data Platform oder jede andere Postgres-Schnittstelle zur Erstellung von Accounts.", "next_steps": "Nächste Schritte", "acquire_commercial_license": "Eine kommerzielle Lizenz erwerben", "the_infrastructure_plan": "Der Infrastrukturplan ist nutzungsbasiert und bietet Startup-freundliche Discounts an.", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 64b0c28f0ae8cd..188d9d21df47ec 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -972,8 +972,6 @@ "enterprise_license_description": "To enable this feature, have an administrator go to <2>/auth/setup to enter a license key. If a license key is already in place, please contact <5>{{SUPPORT_MAIL_ADDRESS}} for help.", "enterprise_license_development": "You can test this feature on development mode. For production usage please have an administrator go to <2>/auth/setup to enter a license key.", "missing_license": "Missing License", - "signup_requires": "Commercial license required", - "signup_requires_description": "{{companyName}} currently does not offer a free open source version of the sign up page. To receive full access to the signup components you need to acquire a commercial license. For personal use we recommend the Prisma Data Platform or any other Postgres interface to create accounts.", "next_steps": "Next Steps", "acquire_commercial_license": "Acquire a commercial license", "the_infrastructure_plan": "The infrastructure plan is usage-based and has startup-friendly discounts.", diff --git a/apps/web/public/static/locales/es/common.json b/apps/web/public/static/locales/es/common.json index 8b57bb30f811ae..ff27586a461b21 100644 --- a/apps/web/public/static/locales/es/common.json +++ b/apps/web/public/static/locales/es/common.json @@ -940,8 +940,6 @@ "enterprise_license_description": "Para habilitar esta función, obtenga una clave de despliegue en la consola {{consoleUrl}} y añádala a su .env como CALCOM_LICENSE_KEY. Si su equipo ya tiene una licencia, póngase en contacto con {{supportMail}} para obtener ayuda.", "enterprise_license_development": "Puede probar esta función en el modo de desarrollo. Para el uso de producción, haga que un administrador vaya a <2>/auth/setup para ingresar una clave de licencia.", "missing_license": "Falta la licencia", - "signup_requires": "Licencia comercial requerida", - "signup_requires_description": "Actualmente, {{companyName}} no ofrece una versión gratuita de código abierto de la página de registro. Para recibir acceso completo a los componentes de registro es necesario adquirir una licencia comercial. Para uso personal, recomendamos la Plataforma de Datos Prisma o cualquier otra interfaz Postgres para crear cuentas.", "next_steps": "Pasos siguientes", "acquire_commercial_license": "Adquirir una licencia comercial", "the_infrastructure_plan": "El plan de infraestructura está basado en el uso y tiene descuentos favorables para empresas emergentes.", diff --git a/apps/web/public/static/locales/fr/common.json b/apps/web/public/static/locales/fr/common.json index a27457c816e007..e5a5a937f58531 100644 --- a/apps/web/public/static/locales/fr/common.json +++ b/apps/web/public/static/locales/fr/common.json @@ -947,8 +947,6 @@ "enterprise_license_description": "Pour activer cette fonctionnalité, demandez à un administrateur d'accéder à <2>/auth/setup pour saisir une clé de licence. Si une clé de licence est déjà en place, veuillez contacter <5>{{SUPPORT_MAIL_ADDRESS}} pour obtenir de l'aide.", "enterprise_license_development": "Vous pouvez tester cette fonctionnalité en mode développement. Pour une utilisation en production, veuillez demander à un administrateur d'aller à <2>/auth/setup pour entrer une clé de licence.", "missing_license": "Licence manquante", - "signup_requires": "Licence commerciale requise", - "signup_requires_description": "{{companyName}} ne propose actuellement pas de version open source gratuite de la page d'inscription. Pour recevoir un accès complet aux composants d'inscription, vous devez acquérir une licence commerciale. Pour un usage personnel, nous recommandons la plateforme de données Prisma ou toute autre interface Postgres pour créer des comptes.", "next_steps": "Prochaines étapes", "acquire_commercial_license": "Obtenir une licence commerciale", "the_infrastructure_plan": "Le plan d'infrastructure est basé sur l'utilisation et propose des réductions adaptées aux startups.", diff --git a/apps/web/public/static/locales/he/common.json b/apps/web/public/static/locales/he/common.json index f16710266b24b6..facdc77e53da2e 100644 --- a/apps/web/public/static/locales/he/common.json +++ b/apps/web/public/static/locales/he/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "כדי להפעיל את יכולת זו, בקש ממנהל לגשת אל הקישור {{setupUrl}} והקלדת הרישיון. אם כבר יש רישיון מוגדר, צור קשר עם {{supportMail}} לעזרה.", "enterprise_license_development": "ניתן לבדוק את התכונה הזו במצב פיתוח. לשימוש מצב הייצור, מנהל/ת מערכת צריך/ה לעבור אל <2>/auth/setup ולהזין מפתח רישיון.", "missing_license": "חסר רישיון", - "signup_requires": "נדרש רישיון לשימוש מסחרי", - "signup_requires_description": "בשלב זה, {{companyName}} אינה מציעה גרסת קוד פתוח חינמית של דף ההרשמה. לקבלת גישה מלאה לרכיבי ההרשמה, תצטרך לקנות רישיון לשימוש מסחרי. לצרכים אישיים אנו ממליצים על Prisma Data Platform או כל ממשק Postgres אחר ליצירת חשבונות.", "next_steps": "השלבים הבאים", "acquire_commercial_license": "רכישת רישיון לשימוש מסחרי", "the_infrastructure_plan": "חבילת הבסיס מחויבת לפי שימוש ומוצעת בהנחות למתחילים.", diff --git a/apps/web/public/static/locales/it/common.json b/apps/web/public/static/locales/it/common.json index 4b8585d6b7f561..5c6dc04dee4c3e 100644 --- a/apps/web/public/static/locales/it/common.json +++ b/apps/web/public/static/locales/it/common.json @@ -940,8 +940,6 @@ "enterprise_license_description": "Per abilitare questa funzione, ottenere una chiave di distribuzione presso la console {{consoleUrl}} e aggiungerla al proprio .env come CALCOM_LICENSE_KEY. Nel caso il team possieda già una licenza, contattare {{supportMail}} per assistenza.", "enterprise_license_development": "Puoi testare questa funzionalità in modalità sviluppo. Per l'utilizzo in produzione, l'amministratore deve accedere a <2>/auth/setup e immettere la chiave di licenza.", "missing_license": "Licenza mancante", - "signup_requires": "È necessaria una licenza commerciale", - "signup_requires_description": "{{companyName}} attualmente non offre una versione open source gratuita della pagina di registrazione. Per avere accesso completo ai componenti di registrazione è necessario acquisire una licenza commerciale. Per un uso personale consigliamo Prisma Data Platform o qualsiasi altra interfaccia Postgres per creare account.", "next_steps": "Prossimi Passi", "acquire_commercial_license": "Acquista una licenza commerciale", "the_infrastructure_plan": "Il piano infrastrutturale è basato sull'utilizzo e ha sconti rivolti alle startup.", diff --git a/apps/web/public/static/locales/ja/common.json b/apps/web/public/static/locales/ja/common.json index 3870f594a7e7f2..1145ab4ff31a3b 100644 --- a/apps/web/public/static/locales/ja/common.json +++ b/apps/web/public/static/locales/ja/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "この機能を有効にするには、{{consoleUrl}} コンソールでデプロイメントキーを入手して .env に CALCOM_LICENSE_KEY として追加してください。既にチームがライセンスを持っている場合は {{supportMail}} にお問い合わせください。", "enterprise_license_development": "この機能は開発モードでテストできます。本番環境で使用するには、管理者に <2>/auth/setup にアクセスするよう依頼し、ライセンスキーを入力してもらってください。", "missing_license": "ライセンスが見つかりません", - "signup_requires": "商用ライセンスが必要です", - "signup_requires_description": "{{companyName}} は、現時点ではサインアップページの無料のオープンソース版を提供しておりません。サインアップコンポーネントへのフルアクセスをご利用いただくためには、商用ライセンスを取得する必要があります。個人での利用につきましては、Prisma Data Platform またはその他の Postgres インターフェースを使用してアカウントの作成を行うことをお勧めしております。", "next_steps": "次のステップ", "acquire_commercial_license": "商用ライセンスを取得する", "the_infrastructure_plan": "インフラストラクチャプランは使用量に応じて課金され、スタートアップに優しい割引制度もあります。", diff --git a/apps/web/public/static/locales/ko/common.json b/apps/web/public/static/locales/ko/common.json index 9490ac7d3a63a1..0887097ac7c0df 100644 --- a/apps/web/public/static/locales/ko/common.json +++ b/apps/web/public/static/locales/ko/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "이 기능을 활성화하려면 {{consoleUrl}} 콘솔에서 배포 키를 가져와 .env에 CALCOM_LICENSE_KEY로 추가하세요. 팀에 이미 라이선스가 있는 경우 {{supportMail}}에 문의하여 도움을 받으세요.", "enterprise_license_development": "개발 모드에서 이 기능을 테스트할 수 있습니다. 프로덕션 용도의 경우 관리자가 <2>/auth/setup으로 이동하여 라이선스 키를 입력하도록 하십시오.", "missing_license": "라이선스 없음", - "signup_requires": "상용 라이선스 필요", - "signup_requires_description": "지금은 {{companyName}}에서 가입 페이지의 무료 오픈 소스 버전을 제공하지 않습니다. 가입 구성 요소에 대한 정식 액세스 권한을 받으려면 상용 라이선스를 취득해야 합니다. 개인 용도의 경우 Prisma Data Platform 또는 기타 Postgres 인터페이스를 사용하여 계정을 생성하는 것이 좋습니다.", "next_steps": "다음 단계", "acquire_commercial_license": "상용 라이선스 취득하기", "the_infrastructure_plan": "인프라 플랜은 사용량 기반이고 스타트업 위주의 할인을 제공합니다.", diff --git a/apps/web/public/static/locales/nl/common.json b/apps/web/public/static/locales/nl/common.json index 8a8bdbb8ad656a..71a0609e4cdeb3 100644 --- a/apps/web/public/static/locales/nl/common.json +++ b/apps/web/public/static/locales/nl/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Om deze functie in te schakelen, krijgt u een implementatiesleutel op de {{consoleUrl}}-console en voegt u deze toe aan uw .env als CALCOM_LICENSE_KEY. Als uw team al een licentie heeft, neem dan contact op met {{supportMail}} voor hulp.", "enterprise_license_development": "U kunt deze functie testen in de in ontwikkelingsmodus. Voor productiegebruik moet een beheerder naar <2>/auth/setup gaan om een licentiecode in te voeren.", "missing_license": "Ontbrekende licentie", - "signup_requires": "Commerciële licentie vereist", - "signup_requires_description": "{{companyName}} biedt momenteel geen gratis opensourceversie van de registratiepagina aan. Om volledige toegang te krijgen tot de registratieonderdelen moet u een commerciële licentie verkrijgen. Voor persoonlijk gebruik raden we aan om accounts aan te maken op het Prisma Data Platform of een andere Postgres-interface.", "next_steps": "Volgende stappen", "acquire_commercial_license": "Verkrijg een commerciële licentie", "the_infrastructure_plan": "Het infrastructuurabonnement is gebaseerd op gebruik en heeft speciale kortingen voor start-ups.", diff --git a/apps/web/public/static/locales/no/common.json b/apps/web/public/static/locales/no/common.json index 7cb795a68063b1..722a7be985cfa1 100644 --- a/apps/web/public/static/locales/no/common.json +++ b/apps/web/public/static/locales/no/common.json @@ -789,8 +789,6 @@ "enterprise_license": "Dette er en bedriftsfunksjon", "enterprise_license_description": "For å aktivere denne funksjonen, skaff deg en distribusjonsnøkkel på {{consoleUrl}}-konsollen og legg den til i .env som CALCOM_LICENSE_KEY. Hvis teamet ditt allerede har en lisens, vennligst kontakt {{supportMail}} for å få hjelp.", "missing_license": "Mangler Lisens", - "signup_requires": "Kommersiell lisens kreves", - "signup_requires_description": "{{companyName}} tilbyr for øyeblikket ikke en gratis åpen kildekode-versjon av registreringssiden. For å få full tilgang til registreringskomponentene må du anskaffe en kommersiell lisens. For personlig bruk anbefaler vi Prisma Data Platform eller et annet Postgres-grensesnitt for å opprette kontoer.", "next_steps": "Neste Skritt", "acquire_commercial_license": "Skaff en kommersiell lisens", "the_infrastructure_plan": "Infrastruktur-planen er bruksbasert og har oppstartsvennlige rabatter.", diff --git a/apps/web/public/static/locales/pl/common.json b/apps/web/public/static/locales/pl/common.json index 3b0bf7c7af3e39..f4965cc628b210 100644 --- a/apps/web/public/static/locales/pl/common.json +++ b/apps/web/public/static/locales/pl/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Aby włączyć tę funkcję, uzyskaj klucz wdrożenia na konsoli {{consoleUrl}} i dodaj go do swojego pliku .env jako CALCOM_LICENSE_KEY. Jeśli Twój zespół ma już licencję, napisz na adres {{supportMail}}, aby uzyskać pomoc.", "enterprise_license_development": "Możesz przetestować tę funkcję w trybie deweloperskim. Aby wykorzystać ją w celach produkcyjnych, poproś administratora o wprowadzenie klucza licencyjnego w obszarze <2>/auth/setup.", "missing_license": "Brakująca licencja", - "signup_requires": "Wymagana licencja handlowa", - "signup_requires_description": "{{companyName}} obecnie nie oferuje darmowej otwartej wersji strony rejestracji. Aby uzyskać pełny dostęp do komponentów rejestracji, musisz zdobyć licencję komercyjną. Do użytku osobistego zalecamy platformę danych Prisma lub jakikolwiek inny interfejs Postgres do tworzenia kont.", "next_steps": "Następne kroki", "acquire_commercial_license": "Uzyskaj licencję komercyjną", "the_infrastructure_plan": "Plan infrastrukturalny opiera się na użytkowaniu i zawiera rabaty sprzyjające uruchomieniu.", diff --git a/apps/web/public/static/locales/pt-BR/common.json b/apps/web/public/static/locales/pt-BR/common.json index adb6b7f0b2e13e..43836e4fd89d53 100644 --- a/apps/web/public/static/locales/pt-BR/common.json +++ b/apps/web/public/static/locales/pt-BR/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Para ativar este recurso, obtenha uma chave de desenvolvimento no console {{consoleUrl}} e adicione ao seu .env como CALCOM_LICENSE_KEY. Caso sua equipe já tenha uma licença, entre em contato com {{supportMail}} para obter ajuda.", "enterprise_license_development": "Você pode testar este recurso no modo de desenvolvimento. Para uso em produção, solicite que um administrador acesse <2>/auth/setup e insira uma chave de licença.", "missing_license": "Falta a licença", - "signup_requires": "Licença comercial obrigatória", - "signup_requires_description": "Atualmente a {{companyName}} não oferece nenhuma versão gratuita de código aberto da página de inscrição. Para obter acesso completo aos componentes da inscrição, você precisa adquirir uma licença comercial. Para uso pessoal, recomendamos o Prisma Data Platform ou outras interfaces em Postgres para criação de contas.", "next_steps": "Próximos passos", "acquire_commercial_license": "Adquira uma licença comercial", "the_infrastructure_plan": "O plano de infraestrutura é baseado no uso e tem descontos acessíveis para novos usuários.", diff --git a/apps/web/public/static/locales/pt/common.json b/apps/web/public/static/locales/pt/common.json index 1d09947a117cdf..cae210c157712c 100644 --- a/apps/web/public/static/locales/pt/common.json +++ b/apps/web/public/static/locales/pt/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Para ativar esta funcionalidade, obtenha uma chave de instalação na consola {{consoleUrl}} e adicione-a ao seu .env como CALCOM_LICENSE_KEY. Se a sua equipa já tem uma licença, entre em contacto com {{supportMail}} para obter ajuda.", "enterprise_license_development": "Pode testar esta funcionalidade no modo de desenvolvimento. Para utilização em produção, um administrador deverá aceder a <2>/auth/setup para inserir a chave da licença.", "missing_license": "Licença em falta", - "signup_requires": "Necessita de licença comercial", - "signup_requires_description": "De momento a {{companyName}} não oferece uma versão gratuita de código aberto da página de registo. Para aceder completamente aos componentes de registo tem de adquirir uma licença comercial. Para uso pessoal, recomendamos a Prisma Data Platform ou qualquer outra interface Postgres para criar contas.", "next_steps": "Passos seguintes", "acquire_commercial_license": "Adquira uma licença comercial", "the_infrastructure_plan": "O plano de infraestrutura é baseado em utilização e tem descontos para quem está a começar.", diff --git a/apps/web/public/static/locales/ro/common.json b/apps/web/public/static/locales/ro/common.json index 5123dc923ab619..6ce6c3fd1a3be8 100644 --- a/apps/web/public/static/locales/ro/common.json +++ b/apps/web/public/static/locales/ro/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Pentru a activa această caracteristică, obține o cheie de implementare la consola {{consoleUrl}} și adaug-o la .env ca CALCOM_LICENSE_KEY. Dacă echipa ta are deja o licență, te rugăm să contactezi {{supportMail}} pentru ajutor.", "enterprise_license_development": "Puteți testa această caracteristică în modul de dezvoltare. Pentru utilizarea în mediul de producție, solicitați unui administrator să acceseze <2>/auth/setup pentru a introduce o cheie de licență.", "missing_license": "Licență lipsă", - "signup_requires": "Licență comercială necesară", - "signup_requires_description": "În prezent, {{companyName}} nu oferă o versiune open-source gratuită a paginii de înscriere. Pentru a primi acces deplin la componentele de înscriere, trebuie să obțineți o licență comercială. Pentru uz personal, recomandăm Prisma Data Platform sau orice altă interfață Postgres pentru a crea conturi.", "next_steps": "Pașii următori", "acquire_commercial_license": "Obțineți o licență comercială", "the_infrastructure_plan": "Planul de infrastructură este bazat pe utilizare și oferă reduceri atractive companiilor de tip start-up.", diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index 61388ad6c7966d..662df1803256f4 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Чтобы включить эту функцию, получите ключ развертывания на консоли {{consoleUrl}} и добавьте его в .env как CALCOM_LICENSE_KEY. Если у вашей команды уже есть лицензия, пожалуйста, обратитесь за помощью в {{supportMail}}.", "enterprise_license_development": "Протестируйте эту функцию в режиме разработки. Для использования в рабочем режиме администратор должен перейти по ссылке <2>/auth/setup и ввести лицензионный ключ.", "missing_license": "Отсутствует лицензия", - "signup_requires": "Требуется коммерческая лицензия", - "signup_requires_description": "В настоящее время компания {{companyName}} не предлагает бесплатную версию страницы регистрации с открытым исходным кодом. Чтобы получить полный доступ к компонентам входа в систему, необходимо приобрести коммерческую лицензию. Для личного пользования мы рекомендуем Prisma Data Platform или любой другой интерфейс Postgres для создания учетных записей.", "next_steps": "Следующие шаги", "acquire_commercial_license": "Приобрести коммерческую лицензию", "the_infrastructure_plan": "Стоимость тарифного плана по развертыванию инфраструктуры зависит от вариантов использования; стартапам предоставляются скидки.", diff --git a/apps/web/public/static/locales/sr/common.json b/apps/web/public/static/locales/sr/common.json index e85033747181f3..b8d3015312ae72 100644 --- a/apps/web/public/static/locales/sr/common.json +++ b/apps/web/public/static/locales/sr/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Da biste omogućili ovu funkciju, nabavite ključ za primenu na {{consoleUrl}} konzoli i dodajte ga u svoj .env kao CALCOM_LICENCE_KEY. Ako vaš tim već ima licencu, obratite se na {{supportMail}} za pomoć.", "enterprise_license_development": "Ovu funkciju možete testirati u razvojnom režimu. Za proizvodnu upotrebu neka administrator ode na <2>/auth/setup da unese ključ za licencu.", "missing_license": "Nedostaje Licenca", - "signup_requires": "Komercijalna dozvola je neophodna", - "signup_requires_description": "{{companyName}} trenutno ne nudi besplatnu verziju otvorenog koda od stranice za prijavu. Da bi dobili potpuni pristup kompenentama za prijavu, neophodno je da ste vlasnik komercijalne licence. Za ličnu upotrebu preporučujemo Prisma Data Platformu ili bilo koji drugi Postgres interfejs za pravljenje naloga.", "next_steps": "Sledeći koraci", "acquire_commercial_license": "Steknite komercijalnu licencu", "the_infrastructure_plan": "Plan infrastrukture je na osnovu upotrebe i ima popuste pogdne za startup.", diff --git a/apps/web/public/static/locales/sv/common.json b/apps/web/public/static/locales/sv/common.json index c40d9049539495..7808b89c274e84 100644 --- a/apps/web/public/static/locales/sv/common.json +++ b/apps/web/public/static/locales/sv/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "För att aktivera den här funktionen hämtar du en distributionsnyckel i konsolen {{consoleUrl}} och lägger till den i din .env som CALCOM_LICENSE_KEY. Om ditt team redan har en licens kan du kontakta {{supportMail}} för att få hjälp.", "enterprise_license_development": "Du kan testa den här funktionen i utvecklingsläge. För produktionsanvändning ska en administratör gå till <2>/auth/setup för att ange en licensnyckel.", "missing_license": "Licens saknas", - "signup_requires": "Kommersiell licens krävs", - "signup_requires_description": "{{companyName}} erbjuder för närvarande inte en gratis open source-version av registreringssidan. För att få full tillgång till de registreringskomponenter du behöver för att skaffa en kommersiell licens. För personligt bruk rekommenderar vi Prisma Data Platform eller något annat Postgres-gränssnitt för att skapa konton.", "next_steps": "Kommande steg", "acquire_commercial_license": "Skaffa en kommersiell licens", "the_infrastructure_plan": "Infrastrukturplanen är användningsbaserad och har start-vänliga rabatter.", diff --git a/apps/web/public/static/locales/tr/common.json b/apps/web/public/static/locales/tr/common.json index a6797a2e9460a4..30bb39d76b150a 100644 --- a/apps/web/public/static/locales/tr/common.json +++ b/apps/web/public/static/locales/tr/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Bu özelliği etkinleştirmek için {{consoleUrl}} konsolundan bir dağıtım anahtarı alın ve bunu .env dosyanıza CALCOM_LICENSE_KEY olarak ekleyin. Ekibinizin zaten bir lisansı varsa yardım için lütfen {{supportMail}} adresinden iletişime geçin.", "enterprise_license_development": "Bu özelliği, geliştirme modunda deneyebilirsiniz. Üretim kullanımı için lütfen bir yöneticinin lisans anahtarını girmesi için <2>/auth/setup adımlarını izlemesini sağlayın.", "missing_license": "Eksik Lisans", - "signup_requires": "Ticari lisans gerekli", - "signup_requires_description": "{{companyName}} şu anda kayıt sayfasının ücretsiz, açık kaynaklı bir sürümünü sunmamaktadır. Kayıt bileşenlerine tam erişim elde etmek için ticari bir lisans almanız gerekiyor. Kişisel kullanım için hesap oluşturmak üzere Prisma Data veya başka bir Postgres arayüzü öneriyoruz.", "next_steps": "Sonraki Adımlar", "acquire_commercial_license": "Ticari bir lisans edinin", "the_infrastructure_plan": "Altyapı planı kullanıma dayalıdır ve yeni başlayanlar için uygun indirimlere sahiptir.", diff --git a/apps/web/public/static/locales/uk/common.json b/apps/web/public/static/locales/uk/common.json index 358a8c508d99d2..40d8c52cf3f6cf 100644 --- a/apps/web/public/static/locales/uk/common.json +++ b/apps/web/public/static/locales/uk/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "Щоб увімкнути цю функцію, отримайте ключ розгортання в консолі {{consoleUrl}} і додайте його у свій файл .env як CALCOM_LICENSE_KEY. Якщо у вашої команди вже є ліцензія, зверніться по допомогу за адресою {{supportMail}}.", "enterprise_license_development": "Ви можете випробувати цю функцію в режимі розробки. Щоб скористатися нею, адміністратору потрібно перейти в розділ <2>«Перевірка»/«Налаштування» і ввести ключ ліцензії.", "missing_license": "Відсутня ліцензія", - "signup_requires": "Потрібна комерційна ліцензія", - "signup_requires_description": "{{companyName}} зараз не надає безкоштовну версію сторінки реєстрації з відкритим кодом. Щоб отримати повний доступ до складових функціоналу реєстрації, потрібно придбати комерційну ліцензію. Для особистого використання та створення облікових записів радимо Prisma Data Platform або будь-який інший інтерфейс Postgres.", "next_steps": "Подальші кроки", "acquire_commercial_license": "Отримати комерційну ліцензію", "the_infrastructure_plan": "Інфраструктурний план базується на використанні та надає знижки для стартапів.", diff --git a/apps/web/public/static/locales/vi/common.json b/apps/web/public/static/locales/vi/common.json index 678d846ace4a8f..b072d4bc900cde 100644 --- a/apps/web/public/static/locales/vi/common.json +++ b/apps/web/public/static/locales/vi/common.json @@ -944,8 +944,6 @@ "enterprise_license_description": "Để bật tính năng này, nhận khoá triển khai tại console {{consoleUrl}} và thêm nó vào .env của bạn ở dạng CALCOM_LICENSE_KEY. Nếu nhóm của bạn đã có giấy phép, vui lòng liên hệ {{supportMail}} để được trợ giúp.", "enterprise_license_development": "Bạn có thể thử nghiệm tính năng này ở chế độ phát triển. Để sử dụng cho sản xuất, hãy nhờ quản trị viên vào phần <2>/auth/setup để nhập một khoá giấy phép.", "missing_license": "Giấy phép bị thiếu", - "signup_requires": "Yêu cầu giấy phép thương mại", - "signup_requires_description": "{{companyName}} hiện không cung cấp phiên bản nguồn mở miễn phí của trang đăng ký. Để nhận toàn quyền truy cập vào các thành phần đăng ký, bạn cần có giấy phép thương mại. Đối với mục đích sử dụng cá nhân, chúng tôi khuyên bạn nên sử dụng Nền tảng dữ liệu Prisma hoặc bất kỳ giao diện Postgres nào khác để tạo tài khoản.", "next_steps": "Bước tiếp theo", "acquire_commercial_license": "Lấy giấy phép thương mại", "the_infrastructure_plan": "Gói cơ sở hạ tầng dựa trên mức sử dụng và có chiết khấu thân thiện với startup.", diff --git a/apps/web/public/static/locales/zh-CN/common.json b/apps/web/public/static/locales/zh-CN/common.json index a0b74eda9f0312..6b61b16df768d8 100644 --- a/apps/web/public/static/locales/zh-CN/common.json +++ b/apps/web/public/static/locales/zh-CN/common.json @@ -942,8 +942,6 @@ "enterprise_license_description": "要启用此功能,请在 {{consoleUrl}} 控制台获取一个部署密钥,并将其添加到您的 .env 中作为 CALCOM_LICENSE_KEY。如果您的团队已经有许可证,请联系 {{supportMail}} 获取帮助。", "enterprise_license_development": "您可以在开发模式下测试此功能。对于生产用途,请让管理员转到 <2>/auth/setup 输入许可证密钥。", "missing_license": "缺少许可证", - "signup_requires": "需要商业许可证", - "signup_requires_description": "{{companyName}} 免费开源版目前不提供的注册页面。 要获得对注册页面组件的完全访问权,您需要获得商业许可证。 对于个人使用用途,我们推荐使用Prisma 数据平台或任何其他Postgres接口创建账户。", "next_steps": "下一步", "acquire_commercial_license": "获取商业许可证", "the_infrastructure_plan": "基础设施计划是按用量计费的,并对初创公司有优惠。", diff --git a/apps/web/public/static/locales/zh-TW/common.json b/apps/web/public/static/locales/zh-TW/common.json index 8e6fad982b847d..958eb144323d12 100644 --- a/apps/web/public/static/locales/zh-TW/common.json +++ b/apps/web/public/static/locales/zh-TW/common.json @@ -941,8 +941,6 @@ "enterprise_license_description": "若要啟用此功能,請在 {{consoleUrl}} 主控台取得部署金鑰,並在您的 .env 中新增為 CALCOM_LICENSE_KEY。如果您的團隊已經取得授權,請聯絡 {{supportMail}} 取得協助。", "enterprise_license_development": "您可以在開發模式下測試此功能。若要用於生產環境,請由管理員前往 <2>/auth/setup 輸入授權金鑰。", "missing_license": "遺失授權", - "signup_requires": "必須有商業授權", - "signup_requires_description": "{{companyName}} 目前不提供註冊頁面的免費開源版本。如果要完整獲得註冊元件,就得要取得商業授權。個人使用的情況,我們推薦 Prisma Data Platform 或其它 Postgres 介面來建立帳號。", "next_steps": "下一步", "acquire_commercial_license": "取得商業授權", "the_infrastructure_plan": "基礎架構方案是根據使用量,並提供新創公司友善折扣。", From 1c2fff5447003c169b1a0f2b4c18310f567bc2ce Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 2 Jan 2024 04:20:56 +0530 Subject: [PATCH 11/42] fix: ui bugs in settings page (#12975) --- packages/features/ee/teams/pages/team-profile-view.tsx | 4 ++-- packages/features/webhooks/pages/webhooks-view.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 8055ebb8e33c76..808b4f379a2041 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -151,7 +151,7 @@ const ProfileView = () => { {isAdmin ? ( ) : ( -
    +
    @@ -167,7 +167,7 @@ const ProfileView = () => { )}
    -
    +
    {t("preview")} diff --git a/packages/features/webhooks/pages/webhooks-view.tsx b/packages/features/webhooks/pages/webhooks-view.tsx index 34c1975f0c9ecf..2cd9e1946cd5ac 100644 --- a/packages/features/webhooks/pages/webhooks-view.tsx +++ b/packages/features/webhooks/pages/webhooks-view.tsx @@ -78,7 +78,7 @@ const WebhooksView = () => { <> ) } - borderInShellHeader={data && data.profiles.length === 1} + borderInShellHeader={(data && data.profiles.length === 1) || !data?.webhookGroups?.length} />
    From ffefb3461ed6caee348b5de6280d99f76da4cf51 Mon Sep 17 00:00:00 2001 From: Ankit Das <40324640+WongChoice@users.noreply.github.com> Date: Tue, 2 Jan 2024 15:02:57 +0530 Subject: [PATCH 12/42] fix: check your email page flickers (#12969) * fixes#12966 * fixes#12966 * "fix-modification * fix-mis-paste * checks-fixes * civilized-code-of-same-previous-approach * civilized-changes-of-same-approach * fix * checks-fix * fix * fix * checks-fix * checksfix * check fix * undochanges-_- --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- apps/web/pages/auth/verify-email.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/pages/auth/verify-email.tsx b/apps/web/pages/auth/verify-email.tsx index 8c66de182fb261..d0a048ca4b5d1a 100644 --- a/apps/web/pages/auth/verify-email.tsx +++ b/apps/web/pages/auth/verify-email.tsx @@ -15,7 +15,7 @@ function VerifyEmailPage() { const { data } = useEmailVerifyCheck(); const { data: session } = useSession(); const router = useRouter(); - const { t } = useLocale(); + const { t, isLocaleReady } = useLocale(); const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation(); useEffect(() => { @@ -24,7 +24,9 @@ function VerifyEmailPage() { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.isVerified]); - + if (!isLocaleReady) { + return null; + } return (
    From cbee4ff7048e38ab6beed61c9dfae8fc3cf2997d Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Tue, 2 Jan 2024 13:45:31 +0000 Subject: [PATCH 13/42] added macos styles to global.css (#12981) --- apps/web/styles/globals.css | 40 ++++++++++++++++++------------- packages/features/shell/Shell.tsx | 6 ++--- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index cdaf8a8cf60d28..bcb55da410404f 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -162,10 +162,6 @@ html.todesktop .desktop-hidden { display: none; } -html.todesktop header { - margin-top: -14px; -} - html.todesktop header nav { margin-top: 8px; } @@ -184,28 +180,38 @@ html.todesktop-platform-darwin.dark main.bg-default { } html.todesktop-platform-darwin.light main.bg-default { - background: rgba(255, 255, 255, 0.8) !important; + background: rgba(255, 255, 255, 0.6) !important; } -/* -html.todesktop aside a { - height: 28px; - padding: 0px 8px; - font-size: 12px; - color: #383438 !important +html.todesktop.light nav a[aria-current="page"]{ + background: #CFD0D0 !important; } -html.todesktop nav a:hover{ - background-color: inherit !important + +html.todesktop.dark nav a[aria-current="page"]{ + background: #3D3D3D !important; } -html.todesktop nav a[aria-current="page"]{ - background: rgba(0, 0, 0, 0.1) !important; +html.todesktop aside header { + margin-top: -12px; + flex-direction: column-reverse; +} + +html.todesktop aside header > div { + width: 100%; +} + +html.todesktop.dark nav a:hover{ + background: inherit; +} + +html.todesktop.light nav a:hover{ + background: none; } html.todesktop nav a svg{ - color: #0272F7 !important -} */ + color: #229CFF !important +} /* Adds Utility to hide scrollbar to tailwind diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index a42e43353afe52..ec9f75dd4a1c5d 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -421,7 +421,7 @@ function UserDropdown({ small }: UserDropdownProps) { setMenuOpen((menuOpen) => !menuOpen)}>
    )} -
    +
    -
    - {/* logo icon for tablet */} From 7015c8909fc253c74fad1d0887455991fe2d94fe Mon Sep 17 00:00:00 2001 From: DmytroHryshyn <125881252+DmytroHryshyn@users.noreply.github.com> Date: Tue, 2 Jan 2024 15:56:50 +0200 Subject: [PATCH 14/42] fix: fix-animations on event-types/[type] page (#12900) --- apps/web/package.json | 2 +- apps/web/pages/event-types/[type]/index.tsx | 26 +++++++++++++++++++++ packages/ui/package.json | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 4fa8646ca1ae9d..90d4f66f7de33b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,7 @@ "@calcom/tsconfig": "*", "@calcom/ui": "*", "@daily-co/daily-js": "^0.37.0", - "@formkit/auto-animate": "^1.0.0-beta.5", + "@formkit/auto-animate": "^0.8.1", "@glidejs/glide": "^3.5.2", "@hookform/error-message": "^2.0.0", "@hookform/resolvers": "^2.9.7", diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 38baf14feb5e95..cdad0b5c1c02f0 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -522,6 +522,32 @@ const EventTypePage = (props: EventTypeSetupProps) => { const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState([]); const slug = formMethods.watch("slug") ?? eventType.slug; + // Optional prerender all tabs after 300 ms on mount + useEffect(() => { + const timeout = setTimeout(() => { + const Components = [ + EventSetupTab, + EventAvailabilityTab, + EventTeamTab, + EventLimitsTab, + EventAdvancedTab, + EventInstantTab, + EventRecurringTab, + EventAppsTab, + EventWorkflowsTab, + EventWebhooksTab, + ]; + + Components.forEach((C) => { + // @ts-expect-error Property 'render' does not exist on type 'ComponentClass + C.render.preload(); + }); + }, 300); + + return () => { + clearTimeout(timeout); + }; + }, []); return ( <> Date: Tue, 2 Jan 2024 19:30:00 +0530 Subject: [PATCH 15/42] fix: preview url for booking page (#12973) * fix: preview url for booking page * chore: use cal url * chore: fix tests --------- Co-authored-by: Peer Richelsen --- apps/web/test/utils/bookingScenario/expects.ts | 6 +++--- .../lib/handleNewBooking/test/fresh-booking.test.ts | 4 ++-- .../lib/handleNewBooking/test/recurring-event.test.ts | 8 ++++---- .../test/team-bookings/collective-scheduling.test.ts | 4 ++-- packages/lib/getBookerUrl/server.ts | 6 +++--- packages/lib/getEventTypeById.ts | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 4486b3eff1f6c8..64f04b4b7a3801 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -8,7 +8,7 @@ import { expect, vi } from "vitest"; import "vitest-fetch-mock"; import dayjs from "@calcom/dayjs"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -382,7 +382,7 @@ export function expectSuccessfulBookingCreationEmails({ bookingTimeRange?: { start: Date; end: Date }; booking: { uid: string; urlOrigin?: string }; }) { - const bookingUrlOrigin = booking.urlOrigin || WEBAPP_URL; + const bookingUrlOrigin = booking.urlOrigin || CAL_URL; expect(emails).toHaveEmail( { titleTag: "confirmed_event_type_subject", @@ -742,7 +742,7 @@ export function expectBookingRequestRescheduledEmails({ booking: { uid: string; urlOrigin?: string }; bookNewTimePath: string; }) { - const bookingUrlOrigin = booking.urlOrigin || WEBAPP_URL; + const bookingUrlOrigin = booking.urlOrigin || CAL_URL; expect(emails).toHaveEmail( { diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index a34790ccfecb40..a487e503df4dec 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -12,7 +12,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { describe, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { resetTestEmails } from "@calcom/lib/testEmails"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -218,7 +218,7 @@ describe("handleNewBooking", () => { expectSuccessfulBookingCreationEmails({ booking: { uid: createdBooking.uid!, - urlOrigin: org ? org.urlOrigin : WEBAPP_URL, + urlOrigin: org ? org.urlOrigin : CAL_URL, }, booker, organizer, diff --git a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts index 393d86ec2c1d71..a0aadf87bf3994 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/recurring-event.test.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import { describe, expect } from "vitest"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL, CAL_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import logger from "@calcom/lib/logger"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -209,7 +209,7 @@ describe("handleNewBooking", () => { booking: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBookings[0].uid!, - urlOrigin: WEBAPP_URL, + urlOrigin: CAL_URL, }, organizer, emails, @@ -555,7 +555,7 @@ describe("handleNewBooking", () => { booking: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBookings[0].uid!, - urlOrigin: WEBAPP_URL, + urlOrigin: CAL_URL, }, organizer, emails, @@ -769,7 +769,7 @@ describe("handleNewBooking", () => { booking: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBookings[0].uid!, - urlOrigin: WEBAPP_URL, + urlOrigin: CAL_URL, }, booker, organizer, diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index d4b0407f88a516..c556b04bbeded0 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -3,7 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { describe, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -1279,7 +1279,7 @@ describe("handleNewBooking", () => { booking: { uid: createdBooking.uid!, // All booking links are of WEBAPP_URL and not of the org because the team isn't part of the org - urlOrigin: WEBAPP_URL, + urlOrigin: CAL_URL, }, booker, organizer, diff --git a/packages/lib/getBookerUrl/server.ts b/packages/lib/getBookerUrl/server.ts index f34c2089439fbf..0031fa79434e27 100644 --- a/packages/lib/getBookerUrl/server.ts +++ b/packages/lib/getBookerUrl/server.ts @@ -1,12 +1,12 @@ -import { WEBAPP_URL } from "../constants"; +import { CAL_URL } from "../constants"; import { getBrand } from "../server/getBrand"; export const getBookerBaseUrl = async (user: { organizationId: number | null }) => { const orgBrand = await getBrand(user.organizationId); - return orgBrand?.fullDomain ?? WEBAPP_URL; + return orgBrand?.fullDomain ?? CAL_URL; }; export const getTeamBookerUrl = async (team: { organizationId: number | null }) => { const orgBrand = await getBrand(team.organizationId); - return orgBrand?.fullDomain ?? WEBAPP_URL; + return orgBrand?.fullDomain ?? CAL_URL; }; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 94b6e14cf0047d..8df07f78686564 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -15,7 +15,7 @@ import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-u import { TRPCError } from "@trpc/server"; -import { WEBAPP_URL } from "./constants"; +import { CAL_URL } from "./constants"; import { getBookerBaseUrl } from "./getBookerUrl/server"; interface getEventTypeByIdProps { @@ -271,7 +271,7 @@ export default async function getEventTypeById({ ? await getBookerBaseUrl({ organizationId: restEventType.team.parentId }) : restEventType.owner ? await getBookerBaseUrl(restEventType.owner) - : WEBAPP_URL, + : CAL_URL, children: restEventType.children.flatMap((ch) => ch.owner !== null ? { From 2181731d64c30210c5c834e2bc4c5fd9141e7d61 Mon Sep 17 00:00:00 2001 From: Robbie Date: Wed, 3 Jan 2024 01:00:38 +1100 Subject: [PATCH 16/42] docs: Add Tunnelmole as an open source alternative to ngrok (#12925) * doc: Add Tunnelmole as an open source alternative to ngrok plus minor grammar fixes * Update README.md --------- Co-authored-by: Peer Richelsen Co-authored-by: Keith Williams --- apps/ai/README.md | 12 +++++--- packages/features/ee/organizations/README.md | 30 +++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/ai/README.md b/apps/ai/README.md index fdba31f3703810..306bf07906e120 100644 --- a/apps/ai/README.md +++ b/apps/ai/README.md @@ -48,17 +48,21 @@ Here is the full architecture: ### Email Router -To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/). +To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool. + +For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe)) + +For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first. To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook). 1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/) 2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication). -3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted. +3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted. 4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain. -5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`. +5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`. 6. Activate "POST the raw, full MIME message". -7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server. +7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server. 8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour. Please feel free to improve any part of this architecture! diff --git a/packages/features/ee/organizations/README.md b/packages/features/ee/organizations/README.md index 53b0a3872b5b51..4723fb73a9b91a 100644 --- a/packages/features/ee/organizations/README.md +++ b/packages/features/ee/organizations/README.md @@ -37,7 +37,35 @@ Browsers do not allow camera/mic access on any non-HTTPS hosts except for localh For eg:- Use `http://localhost:3000/video/nAjnkjejuzis99NhN72rGt` instead of `http://app.cal.local:3000/video/nAjnkjejuzis99NhN72rGt`. -You can also use `ngrok` or you can generate SSL certificate for your local domain using `mkcert`. +To get an HTTPS URL for localhost, you can use a tunneling tool such as `ngrok` or [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) . Alternatively, you can generate an SSL certificate for your local domain using `mkcert`. Turn off any SSL certificate validation in your HTTPS client (be sure to do this for local only, otherwise its a security risk). + +#### Tunnelmole - Open Source Tunnelling Tool: + +To install Tunnelmole, execute the command: + +``` +curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install +``` + +After a successful installation, you can run Tunnelmole using the following command, replacing `8000` with your actual port number if it is different: + +``` +tmole 8000 +``` + +In the output, you'll see two URLs, one HTTP and an HTTPS URL. For privacy and security reasons, it is recommended to use the HTTPS URL. + +View the Tunnelmole [README](https://github.com/robbie-cahill/tunnelmole-client) for additional information and other installation methods such as `npm` or building your own binaries from source. + +#### ngrok - Closed Source Tunnelling Tool: + +ngrok is a popular closed source tunneling tool. You can run ngrok using the same port, using the format `ngrok http ` replacing `` with your actual port number. For example: + +``` +ngrok http 8000 +``` + +This will generate a public URL that you can use to access your localhost server. ## DNS setup From f848a44f1a3aced1a3c2a6960f3be7d94e501b77 Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Tue, 2 Jan 2024 19:38:11 +0530 Subject: [PATCH 17/42] feat: integrate formbricks in help feedback box (#12276) * feat: integrate formbricks in help feedback box * Update yarn.lock * Update yarn.lock * fix: use formbricks/api@v1.1 & set user with userId linked to feedback * fix: use separate env vars as suggested * test: Add more orgs tests (#12241) * feat: integrate formbricks in help feedback box * Update yarn.lock * fix: yarn lockfile * fix: yarn lockfile again * feat: link cal and formbricks user.id and add attributes of email and username to formbricks person object * Update yarn.lock * Update yarn.lock * fix: type safety in enums --------- Co-authored-by: Peer Richelsen Co-authored-by: Hariom Balhara Co-authored-by: Peer Richelsen --- .env.example | 5 +++ packages/lib/formbricks.ts | 42 +++++++++++++++++++ packages/lib/package.json | 1 + .../loggedInViewer/submitFeedback.handler.ts | 3 ++ turbo.json | 3 ++ 5 files changed, 54 insertions(+) create mode 100644 packages/lib/formbricks.ts diff --git a/.env.example b/.env.example index 71357c2d7c91c9..e15bdefcd9c150 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,11 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME= # Used for capturing exceptions and logging messages NEXT_PUBLIC_SENTRY_DSN= +# Formbricks Experience Management Integration +FORMBRICKS_HOST_URL=https://app.formbricks.com +FORMBRICKS_ENVIRONMENT_ID= +FORMBRICKS_FEEDBACK_SURVEY_ID= + # Twilio # Used to send SMS reminders in workflows TWILIO_SID= diff --git a/packages/lib/formbricks.ts b/packages/lib/formbricks.ts new file mode 100644 index 00000000000000..b5ed06a546e348 --- /dev/null +++ b/packages/lib/formbricks.ts @@ -0,0 +1,42 @@ +import { FormbricksAPI } from "@formbricks/api"; + +import type { Feedback } from "@calcom/emails/templates/feedback-email"; + +enum Rating { + "Extremely unsatisfied" = 1, + "Unsatisfied" = 2, + "Satisfied" = 3, + "Extremely satisfied" = 4, +} + +export const sendFeedbackFormbricks = async (userId: number, feedback: Feedback) => { + if (!process.env.FORMBRICKS_HOST_URL || !process.env.FORMBRICKS_ENVIRONMENT_ID) + throw new Error("Missing FORMBRICKS_HOST_URL or FORMBRICKS_ENVIRONMENT_ID env variable"); + const api = new FormbricksAPI({ + apiHost: process.env.FORMBRICKS_HOST_URL, + environmentId: process.env.FORMBRICKS_ENVIRONMENT_ID, + }); + if (process.env.FORMBRICKS_FEEDBACK_SURVEY_ID) { + const formbricksUserId = userId.toString(); + const ratingValue = Object.keys(Rating).includes(feedback.rating) + ? Rating[feedback.rating as keyof typeof Rating] + : undefined; + if (ratingValue === undefined) throw new Error("Invalid rating value"); + + await api.client.response.create({ + surveyId: process.env.FORMBRICKS_FEEDBACK_SURVEY_ID, + userId: formbricksUserId, + finished: true, + data: { + "formbricks-share-comments-question": feedback.comment, + "formbricks-rating-question": ratingValue, + }, + }); + await api.client.people.update(formbricksUserId, { + attributes: { + email: feedback.email, + username: feedback.username, + }, + }); + } +}; diff --git a/packages/lib/package.json b/packages/lib/package.json index b2ada1df571bf3..fae3a15b8f4b8c 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -14,6 +14,7 @@ "dependencies": { "@calcom/config": "*", "@calcom/dayjs": "*", + "@formbricks/api": "^1.1.0", "@sendgrid/client": "^7.7.0", "@vercel/og": "^0.5.0", "bcryptjs": "^2.4.3", diff --git a/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts index 27db135abbb84a..e72932778b1904 100644 --- a/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/submitFeedback.handler.ts @@ -1,5 +1,6 @@ import dayjs from "@calcom/dayjs"; import { sendFeedbackEmail } from "@calcom/emails"; +import { sendFeedbackFormbricks } from "@calcom/lib/formbricks"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -30,6 +31,8 @@ export const submitFeedbackHandler = async ({ ctx, input }: SubmitFeedbackOption comment: comment, }, }); + if (process.env.FORMBRICKS_HOST_URL && process.env.FORMBRICKS_ENVIRONMENT_ID) + sendFeedbackFormbricks(ctx.user.id, feedback); if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback); }; diff --git a/turbo.json b/turbo.json index b0ed2de782c596..e7f02741413c31 100644 --- a/turbo.json +++ b/turbo.json @@ -256,6 +256,9 @@ "EMAIL_SERVER_USER", "EMAIL_SERVER", "EXCHANGE_DEFAULT_EWS_URL", + "FORMBRICKS_HOST_URL", + "FORMBRICKS_ENVIRONMENT_ID", + "FORMBRICKS_FEEDBACK_SURVEY_ID", "GIPHY_API_KEY", "GITHUB_API_REPO_TOKEN", "GOOGLE_API_CREDENTIALS", From 1a3e4d10c0171c247e70adc39a73814fa1a0d445 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Tue, 2 Jan 2024 09:34:59 -0500 Subject: [PATCH 18/42] chore: Update permissions used for assigning team labels (#12984) * chore: Inherit secrets for assigning team labels * Added workflow_call for testing * Added this branch for testing * Moved secrets label * Trying to set permissions differently * Removed testing branch --- .github/workflows/pr-assign-team-label.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml index ecb601f75c45f3..3004285940b5d7 100644 --- a/.github/workflows/pr-assign-team-label.yml +++ b/.github/workflows/pr-assign-team-label.yml @@ -1,11 +1,15 @@ name: Assign PR team labels on: + workflow_call: pull_request: branches: - main jobs: team-labels: + permissions: + contents: read + pull-requests: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 From 218f6a84b93f85047d48ecaa009f387dce91276e Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Tue, 2 Jan 2024 14:54:28 +0000 Subject: [PATCH 19/42] fix: layout for settings/teams (#12979) * fixed url * added settings fix --- packages/features/settings/layouts/SettingsLayout.tsx | 2 +- packages/features/settings/layouts/SettingsLayoutAppDir.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 70721e3cfb98f5..333a0c493aa45f 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -103,7 +103,7 @@ const tabs: VerticalTabItemProps[] = [ }, { name: "teams", - href: "/settings/teams", + href: "/teams", icon: Users, children: [], }, diff --git a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx index 7719211f1b7471..92108d07f49288 100644 --- a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx +++ b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx @@ -105,7 +105,7 @@ const tabs: VerticalTabItemProps[] = [ }, { name: "teams", - href: "/settings/teams", + href: "/teams", icon: Users, children: [], }, From e39e6ccf791ef7936c1b7b76f9c771e08ba8ecd0 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Tue, 2 Jan 2024 11:07:58 -0500 Subject: [PATCH 20/42] chore: Merge PR labeler actions (#12987) --- .../workflows/apply-issue-labels-to-pr.yml | 74 ------------------ .github/workflows/labeler.yml | 78 +++++++++++++++++++ .github/workflows/pr-assign-team-label.yml | 20 ----- 3 files changed, 78 insertions(+), 94 deletions(-) delete mode 100644 .github/workflows/apply-issue-labels-to-pr.yml delete mode 100644 .github/workflows/pr-assign-team-label.yml diff --git a/.github/workflows/apply-issue-labels-to-pr.yml b/.github/workflows/apply-issue-labels-to-pr.yml deleted file mode 100644 index 3299b591a8ad50..00000000000000 --- a/.github/workflows/apply-issue-labels-to-pr.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: "Apply issue labels to PR" - -on: - pull_request_target: - types: - - opened - -jobs: - label_on_pr: - runs-on: ubuntu-latest - - permissions: - contents: none - issues: read - pull-requests: write - - steps: - - name: Apply labels from linked issue to PR - uses: actions/github-script@v5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - async function getLinkedIssues(owner, repo, prNumber) { - const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - closingIssuesReferences(first: 10) { - nodes { - number - labels(first: 10) { - nodes { - name - } - } - } - } - } - } - }`; - - const variables = { - owner: owner, - repo: repo, - prNumber: prNumber, - }; - - const result = await github.graphql(query, variables); - return result.repository.pullRequest.closingIssuesReferences.nodes; - } - - const pr = context.payload.pull_request; - const linkedIssues = await getLinkedIssues( - context.repo.owner, - context.repo.repo, - pr.number - ); - - const labelsToAdd = new Set(); - for (const issue of linkedIssues) { - if (issue.labels && issue.labels.nodes) { - for (const label of issue.labels.nodes) { - labelsToAdd.add(label.name); - } - } - } - - if (labelsToAdd.size) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: Array.from(labelsToAdd), - }); - } diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8c5f9c18295c47..0ed17ef62afb87 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -16,3 +16,81 @@ jobs: repo-token: "${{ secrets.GITHUB_TOKEN }}" # https://github.com/actions/labeler/issues/442#issuecomment-1297359481 sync-labels: "" + team-labels: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: equitybee/team-label-action@main + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + organization-name: calcom + 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" + apply-labels-from-issue: + runs-on: ubuntu-latest + + permissions: + contents: none + issues: read + pull-requests: write + + steps: + - name: Apply labels from linked issue to PR + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + async function getLinkedIssues(owner, repo, prNumber) { + const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 10) { + nodes { + number + labels(first: 10) { + nodes { + name + } + } + } + } + } + } + }`; + + const variables = { + owner: owner, + repo: repo, + prNumber: prNumber, + }; + + const result = await github.graphql(query, variables); + return result.repository.pullRequest.closingIssuesReferences.nodes; + } + + const pr = context.payload.pull_request; + const linkedIssues = await getLinkedIssues( + context.repo.owner, + context.repo.repo, + pr.number + ); + + const labelsToAdd = new Set(); + for (const issue of linkedIssues) { + if (issue.labels && issue.labels.nodes) { + for (const label of issue.labels.nodes) { + labelsToAdd.add(label.name); + } + } + } + + if (labelsToAdd.size) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: Array.from(labelsToAdd), + }); + } diff --git a/.github/workflows/pr-assign-team-label.yml b/.github/workflows/pr-assign-team-label.yml deleted file mode 100644 index 3004285940b5d7..00000000000000 --- a/.github/workflows/pr-assign-team-label.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Assign PR team labels -on: - workflow_call: - pull_request: - branches: - - main - -jobs: - team-labels: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: equitybee/team-label-action@main - with: - repo-token: ${{ secrets.GH_ACCESS_TOKEN }} - organization-name: calcom - 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 299a866aacf101c8d9abfa2803ce0a75b8e17297 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:54:45 +0530 Subject: [PATCH 21/42] fix: dynamic booking duration (#12951) * dynamic booking duration fix * fix: Correct duration handling from boolean -> number --------- Co-authored-by: Alex van Andel --- .../Booker/components/BookEventForm/BookEventForm.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index dc34e661b2185c..0ce4dfbd6565e7 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -338,12 +338,11 @@ export const BookEventFormChild = ({ // Ensures that duration is an allowed value, if not it defaults to the // default eventQuery duration. - const validDuration = - duration && - eventQuery.data.metadata?.multipleDuration && - eventQuery.data.metadata?.multipleDuration.includes(duration) - ? duration - : eventQuery.data.length; + const validDuration = eventQuery.data.isDynamic + ? duration || eventQuery.data.length + : duration && eventQuery.data.metadata?.multipleDuration?.includes(duration) + ? duration + : eventQuery.data.length; const bookingInput = { values, From e61be783d12091ea8de8cdf96c351706acc772bd Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 21:56:48 +0000 Subject: [PATCH 22/42] test: Check the recurring event tab and your funtionalities (teste2e-recurring) (#12331) Co-authored-by: gitstart-calcom Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: Keith Williams --- .../booking/recurringBooking.e2e.ts | 28 +++++++++++++++++ .../playwright/fixtures/regularBookings.ts | 30 +++++++++++++++++++ .../components/event-meta/Occurences.tsx | 5 ++-- .../components/EventTypeDescription.tsx | 2 +- 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 apps/web/playwright/booking/recurringBooking.e2e.ts diff --git a/apps/web/playwright/booking/recurringBooking.e2e.ts b/apps/web/playwright/booking/recurringBooking.e2e.ts new file mode 100644 index 00000000000000..5f89ef9849567b --- /dev/null +++ b/apps/web/playwright/booking/recurringBooking.e2e.ts @@ -0,0 +1,28 @@ +/* eslint-disable playwright/no-conditional-in-test */ +import { loginUser } from "../fixtures/regularBookings"; +import { test } from "../lib/fixtures"; + +test.describe.configure({ mode: "serial" }); + +test.describe("Booking with recurring checked", () => { + test.beforeEach(async ({ page, users, bookingPage }) => { + await loginUser(users); + await page.goto("/event-types"); + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("recurring"); + }); + + test("Updates event type with recurring events", async ({ page, bookingPage }) => { + await bookingPage.updateRecurringTab("2", "3"); + await bookingPage.updateEventType(); + await page.getByRole("link", { name: "Event Types" }).click(); + await bookingPage.assertRepeatEventType(); + }); + + test("Updates and shows recurring schedule correctly in booking page", async ({ bookingPage }) => { + await bookingPage.updateRecurringTab("2", "3"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.fillRecurringFieldAndConfirm(eventTypePage); + }); +}); diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts index 93671b0a59f72a..18f2f0a5d5f2bf 100644 --- a/apps/web/playwright/fixtures/regularBookings.ts +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test"; import dayjs from "@calcom/dayjs"; +import { localize } from "../lib/testUtils"; import type { createUsersFixture } from "./users"; const reschedulePlaceholderText = "Let others know why you need to reschedule"; @@ -220,6 +221,23 @@ export function createBookingPageFixture(page: Page) { } await page.getByTestId("field-add-save").click(); }, + updateRecurringTab: async (repeatWeek: string, maxEvents: string) => { + const repeatText = (await localize("en"))("repeats_every"); + const maximumOf = (await localize("en"))("for_a_maximum_of"); + await page.getByTestId("recurring-event-check").click(); + await page + .getByTestId("recurring-event-collapsible") + .locator("div") + .filter({ hasText: repeatText }) + .getByRole("spinbutton") + .fill(repeatWeek); + await page + .getByTestId("recurring-event-collapsible") + .locator("div") + .filter({ hasText: maximumOf }) + .getByRole("spinbutton") + .fill(maxEvents); + }, updateEventType: async () => { await page.getByTestId("update-eventtype").click(); }, @@ -246,6 +264,14 @@ export function createBookingPageFixture(page: Page) { await page.getByTestId("confirm-reschedule-button").click(); }, + fillRecurringFieldAndConfirm: async (eventTypePage: Page) => { + await eventTypePage.getByTestId("occurrence-input").click(); + await eventTypePage.getByTestId("occurrence-input").fill("2"); + await goToNextMonthIfNoAvailabilities(eventTypePage); + await eventTypePage.getByTestId("time").first().click(); + await expect(eventTypePage.getByTestId("recurring-dates")).toBeVisible(); + }, + cancelBookingWithReason: async (page: Page) => { await page.getByTestId("cancel").click(); await page.getByTestId("cancel_reason").fill("Test cancel"); @@ -279,6 +305,10 @@ export function createBookingPageFixture(page: Page) { await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible(); }, + assertRepeatEventType: async () => { + await expect(page.getByTestId("repeat-eventtype")).toBeVisible(); + }, + cancelBooking: async (eventTypePage: Page) => { await eventTypePage.getByTestId("cancel").click(); await eventTypePage.getByTestId("cancel_reason").fill("Test cancel"); diff --git a/packages/features/bookings/components/event-meta/Occurences.tsx b/packages/features/bookings/components/event-meta/Occurences.tsx index fbed0add185b9f..38cbdadcdd4ce9 100644 --- a/packages/features/bookings/components/event-meta/Occurences.tsx +++ b/packages/features/bookings/components/event-meta/Occurences.tsx @@ -47,7 +47,7 @@ export const EventOccurences = ({ event }: { event: PublicEvent }) => { i18n.language ); return ( - <> +
    {recurringStrings.slice(0, 5).map((timeFormatted, key) => (

    {timeFormatted}

    ))} @@ -59,7 +59,7 @@ export const EventOccurences = ({ event }: { event: PublicEvent }) => {

    + {t("plus_more", { count: recurringStrings.length - 5 })}

    )} - +
    ); } @@ -73,6 +73,7 @@ export const EventOccurences = ({ event }: { event: PublicEvent }) => { min="1" max={event.recurringEvent.count} defaultValue={occurenceCount || event.recurringEvent.count} + data-testid="occurrence-input" onChange={(event) => { const pattern = /^(?=.*[0-9])\S+$/; const inputValue = parseInt(event.target.value); diff --git a/packages/features/eventtypes/components/EventTypeDescription.tsx b/packages/features/eventtypes/components/EventTypeDescription.tsx index ec8f15cd4c5a16..437e425f1d643b 100644 --- a/packages/features/eventtypes/components/EventTypeDescription.tsx +++ b/packages/features/eventtypes/components/EventTypeDescription.tsx @@ -86,7 +86,7 @@ export const EventTypeDescription = ({ )} {recurringEvent?.count && recurringEvent.count > 0 && ( -
  • +
  • {t("repeats_up_to", { count: recurringEvent.count, From 4dba72fecbbcdf5d18c4dde131bfead8be0cc16a Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Tue, 2 Jan 2024 17:39:39 -0500 Subject: [PATCH 23/42] fix: Permissions issue with team labeler (#12992) * fix: Permissions issue with team labeler * Made the job runnable --- .github/workflows/labeler.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 0ed17ef62afb87..44978d72d6e11d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,6 +1,7 @@ name: "Pull Request Labeler" on: - pull_request_target + - workflow_call concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -25,7 +26,7 @@ jobs: - uses: actions/checkout@v2 - uses: equitybee/team-label-action@main with: - repo-token: ${{ secrets.GITHUB_TOKEN }} + repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }} organization-name: calcom 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" apply-labels-from-issue: From c7b62950de2ccaf8ce339ceba5afc768327a4a69 Mon Sep 17 00:00:00 2001 From: Kartik Saini <41051387+kart1ka@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:05:46 +0530 Subject: [PATCH 24/42] fix: cal video link on booking confirmation (#12944) * fix: Ensure generated Cal Video link matches expected pattern on booking confirmation * fix: missing Google Meet videoCallUrl in webhooks on booking confirmation --------- Co-authored-by: Peer Richelsen Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../features/bookings/lib/handleConfirmation.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index fd23e806994376..14bc68817d487d 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -9,6 +9,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger"; import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; +import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -138,6 +139,7 @@ export async function handleConfirmation(args: { }[] = []; const videoCallUrl = metadata.hangoutLink ? metadata.hangoutLink : evt.videoCallData?.url || ""; + const meetingUrl = getVideoCallUrlFromCalEvent(evt) || videoCallUrl; if (recurringEventId) { // The booking to confirm is a recurring event and comes from /booking/recurring, proceeding to mark all related @@ -162,7 +164,7 @@ export async function handleConfirmation(args: { paid, metadata: { ...(typeof recurringBooking.metadata === "object" ? recurringBooking.metadata : {}), - videoCallUrl, + videoCallUrl: meetingUrl, }, }, select: { @@ -215,7 +217,10 @@ export async function handleConfirmation(args: { references: { create: scheduleResult.referencesToCreate, }, - metadata: { ...(typeof booking.metadata === "object" ? booking.metadata : {}), videoCallUrl }, + metadata: { + ...(typeof booking.metadata === "object" ? booking.metadata : {}), + videoCallUrl: meetingUrl, + }, }, select: { eventType: { @@ -258,7 +263,11 @@ export async function handleConfirmation(args: { try { for (let index = 0; index < updatedBookings.length; index++) { const eventTypeSlug = updatedBookings[index].eventType?.slug || ""; - const evtOfBooking = { ...evt, metadata: { videoCallUrl }, eventType: { slug: eventTypeSlug } }; + const evtOfBooking = { + ...evt, + metadata: { videoCallUrl: meetingUrl }, + eventType: { slug: eventTypeSlug }, + }; evtOfBooking.startTime = updatedBookings[index].startTime.toISOString(); evtOfBooking.endTime = updatedBookings[index].endTime.toISOString(); evtOfBooking.uid = updatedBookings[index].uid; @@ -341,7 +350,7 @@ export async function handleConfirmation(args: { eventTypeId: booking.eventType?.id, status: "ACCEPTED", smsReminderNumber: booking.smsReminderNumber || undefined, - metadata: evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined, + metadata: meetingUrl ? { videoCallUrl: meetingUrl } : undefined, }).catch((e) => { console.error( `Error executing webhook for event: ${WebhookTriggerEvents.BOOKING_CREATED}, URL: ${sub.subscriberUrl}`, From 6dec98129cd70f985889feb58a482480276dc1c3 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 3 Jan 2024 20:05:29 +0530 Subject: [PATCH 25/42] fix: bad request in webhook route (#13008) --- apps/web/pages/api/recorded-daily-video.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index 9886fc686347fb..412d73bbfca47e 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -21,16 +21,16 @@ const schema = z id: z.string(), payload: z.object({ recording_id: z.string(), - end_ts: z.number(), + end_ts: z.number().optional(), room_name: z.string(), - start_ts: z.number(), + start_ts: z.number().optional(), status: z.string(), - max_participants: z.number(), - duration: z.number(), - s3_key: z.string(), + max_participants: z.number().optional(), + duration: z.number().optional(), + s3_key: z.string().optional(), }), - event_ts: z.number(), + event_ts: z.number().optional(), }) .passthrough(); From 7d7e74c869b8ced3be19ef6306ba5cbaa6f08124 Mon Sep 17 00:00:00 2001 From: Riddhesh Mahajan <40472653+riddhesh-mahajan@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:16:10 +0530 Subject: [PATCH 26/42] fix: skeleton width for settings menu in sidebar (#13017) --- packages/features/shell/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index ec9f75dd4a1c5d..10f7292007d7de 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -974,7 +974,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
    {t(item.name)}
    ) : ( - + )} From c4b296d580351697d31be8c25ed7723b6dfb18c6 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 3 Jan 2024 16:54:44 +0000 Subject: [PATCH 27/42] chore: replace global.css with todesktop tailwind variant (#12991) * installed todesktop tailwind variant * moved todesktop styles into tailwind classes * fixed back button in settings --- apps/web/app/layout.tsx | 2 +- .../components/auth/layouts/AdminLayout.tsx | 2 +- apps/web/pages/_document.tsx | 2 +- apps/web/styles/globals.css | 62 ++----------------- packages/config/package.json | 1 + packages/config/tailwind-preset.js | 1 + packages/features/kbar/Kbar.tsx | 2 +- .../settings/layouts/SettingsLayout.tsx | 2 +- packages/features/shell/Shell.tsx | 28 +++++---- .../data-table/DataTableToolbar.tsx | 8 ++- .../ui/components/popover/AnimatedPopover.tsx | 6 +- 11 files changed, 37 insertions(+), 79 deletions(-) diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 579ba8b770b67d..ecdfb2cd8da185 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -84,7 +84,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo `} -
    +
    *]:flex-1"}> {children}
    diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index 6ffa6f293df260..f1755445ed0343 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -87,7 +87,7 @@ class MyDocument extends Document { div { - width: 100%; +html.todesktop.light { + --cal-bg-emphasis: hsla(0, 0%, 11%, 0.1); } -html.todesktop.dark nav a:hover{ - background: inherit; +html.todesktop.dark { + --cal-bg-emphasis: hsla(220, 2%, 26%, 0.3); } -html.todesktop.light nav a:hover{ - background: none; -} - -html.todesktop nav a svg{ - color: #229CFF !important -} - /* Adds Utility to hide scrollbar to tailwind https://github.com/tailwindlabs/tailwindcss/discussions/2394 diff --git a/packages/config/package.json b/packages/config/package.json index 14d083319cd112..3001d202e75c45 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -11,6 +11,7 @@ ], "dependencies": { "@calcom/eslint-plugin-eslint": "*", + "@todesktop/tailwind-variants": "^1.0.0", "eslint-config-next": "^13.2.1", "eslint-config-prettier": "^8.6.0", "eslint-config-turbo": "^0.0.7", diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index 6e70d0c465f6d6..b72727f7f9998f 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -150,6 +150,7 @@ module.exports = { }, }, plugins: [ + require("@todesktop/tailwind-variants"), require("@tailwindcss/forms"), require("@tailwindcss/typography"), require("tailwind-scrollbar")({ nocompatible: true }), diff --git a/packages/features/kbar/Kbar.tsx b/packages/features/kbar/Kbar.tsx index 0477141a3cb717..cdc1fa7cc69bd8 100644 --- a/packages/features/kbar/Kbar.tsx +++ b/packages/features/kbar/Kbar.tsx @@ -273,7 +273,7 @@ export const KBarTrigger = () => { diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 333a0c493aa45f..704bdd52d8d879 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -175,7 +175,7 @@ const BackButtonInSidebar = ({ name }: { name: string }) => { return ( diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 10f7292007d7de..5f3d015314674d 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -421,7 +421,7 @@ function UserDropdown({ small }: UserDropdownProps) { setMenuOpen((menuOpen) => !menuOpen)}> {!!orgBranding && ( @@ -1041,7 +1043,7 @@ export function ShellMain(props: LayoutProps) { props.backPath ? "relative" : "pwa:bottom-[max(7rem,_calc(5rem_+_env(safe-area-inset-bottom)))] fixed bottom-20 z-40 ltr:right-4 rtl:left-4 md:z-auto md:ltr:right-0 md:rtl:left-0", - "flex-shrink-0 md:relative md:bottom-auto md:right-auto" + "flex-shrink-0 [-webkit-app-region:no-drag] md:relative md:bottom-auto md:right-auto" )}> {isLocaleReady && props.CTA}
    diff --git a/packages/ui/components/data-table/DataTableToolbar.tsx b/packages/ui/components/data-table/DataTableToolbar.tsx index 0b9be001a7dc8a..2c63ef20c01f96 100644 --- a/packages/ui/components/data-table/DataTableToolbar.tsx +++ b/packages/ui/components/data-table/DataTableToolbar.tsx @@ -4,6 +4,8 @@ import type { Table } from "@tanstack/react-table"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import { Button } from "../button"; import { Input } from "../form"; import { DataTableFilter } from "./DataTableFilter"; @@ -35,8 +37,10 @@ export function DataTableToolbar({ // If you select ALL filters for a column, the table is not filtered and we dont get a reset button const isFiltered = table.getState().columnFilters.length > 0; + const { t } = useLocale(); + return ( -
    +
    {searchKey && ( ({ EndIcon={X} onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3"> - Reset + {t("clear")} )} diff --git a/packages/ui/components/popover/AnimatedPopover.tsx b/packages/ui/components/popover/AnimatedPopover.tsx index cd3e6e3b568a0a..b0d43a5ef2d281 100644 --- a/packages/ui/components/popover/AnimatedPopover.tsx +++ b/packages/ui/components/popover/AnimatedPopover.tsx @@ -24,7 +24,7 @@ export const AnimatedPopover = ({ prefix?: string; }) => { const [open, setOpen] = React.useState(defaultOpen ?? false); - const ref = React.useRef(null); + const ref = React.useRef(null); // calculate which aligment to open the popover with based on which half of the screen it is on (left or right) const [align, setAlign] = React.useState<"start" | "end">("start"); React.useEffect(() => { @@ -50,7 +50,7 @@ export const AnimatedPopover = ({ return ( -
    )} -
    +
    Date: Wed, 3 Jan 2024 14:51:24 -0500 Subject: [PATCH 28/42] chore: Remove need for force merges (#13021) --- .github/workflows/pr.yml | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5395b1bd847190..9a507bfc9ad7a9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,9 +4,6 @@ on: pull_request_target: branches: - main - paths-ignore: - - "**.md" - - ".github/CODEOWNERS" merge_group: workflow_dispatch: @@ -15,36 +12,62 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect changes + runs-on: buildjet-4vcpu-ubuntu-2204 + permissions: + pull-requests: read + outputs: + has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }} + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/dangerous-git-checkout + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + has-files-requiring-all-checks: + - "!(**.md|.github/CODEOWNERS)" type-check: name: Type check + needs: [changes] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/check-types.yml secrets: inherit test: name: Unit tests + needs: [changes] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/unit-tests.yml secrets: inherit lint: name: Linters + needs: [changes] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/lint.yml secrets: inherit build: name: Production build + needs: [changes] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/production-build.yml secrets: inherit analyze: - needs: build + name: Analyze Build + needs: [changes, build] + if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }} uses: ./.github/workflows/nextjs-bundle-analysis.yml secrets: inherit required: - needs: [lint, type-check, test, build] + needs: [changes, lint, type-check, test, build] if: always() runs-on: buildjet-4vcpu-ubuntu-2204 steps: - name: fail if conditional jobs failed - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled') + if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')) run: exit 1 From f201266d69ff34056fb6bc54b59a676b936e3032 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Wed, 3 Jan 2024 21:29:11 +0000 Subject: [PATCH 29/42] chore: [app-router-migration-7] Migrate `/teams` page (#12622) Co-authored-by: Dmytro Hryshyn Co-authored-by: zomars --- .env.example | 3 ++- apps/web/abTest/middlewareFactory.ts | 1 + apps/web/middleware.ts | 2 ++ apps/web/playwright/teams.e2e.ts | 29 ++++++++++++++++++++++++++++ turbo.json | 1 + 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e15bdefcd9c150..3b7cf159e6d2dd 100644 --- a/.env.example +++ b/.env.example @@ -327,6 +327,7 @@ APP_ROUTER_SETTINGS_TEAMS_ENABLED=0 APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0 APP_ROUTER_APPS_ENABLED=0 APP_ROUTER_VIDEO_ENABLED=0 +APP_ROUTER_TEAMS_ENABLED=0 # disable setry server source maps -SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 \ No newline at end of file +SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts index 7c2cf36832a2a7..ef5c93ebc79f05 100644 --- a/apps/web/abTest/middlewareFactory.ts +++ b/apps/web/abTest/middlewareFactory.ts @@ -17,6 +17,7 @@ const ROUTES: [URLPattern, boolean][] = [ ["/apps", process.env.APP_ROUTER_APPS_ENABLED === "1"] as const, ["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const, ["/video/:path*", process.env.APP_ROUTER_VIDEO_ENABLED === "1"] as const, + ["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const, ].map(([pathname, enabled]) => [ new URLPattern({ pathname, diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index c2025b14d70385..213869dbfa2120 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -142,6 +142,8 @@ export const config = { "/future/bookings/:status/", "/video/:path*", "/future/video/:path*", + "/teams", + "/future/teams/", ], }; diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 0491241e0b6c6f..51f1976a439593 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -17,6 +17,35 @@ import { test.describe.configure({ mode: "parallel" }); +test.describe("Teams A/B tests", () => { + test("should point to the /future/teams page", async ({ page, users, context }) => { + await context.addCookies([ + { + name: "x-calcom-future-routes-override", + value: "1", + url: "http://localhost:3000", + }, + ]); + const user = await users.create(); + + await user.apiLogin(); + + await page.goto("/teams"); + + await page.waitForLoadState(); + + const dataNextJsRouter = await page.evaluate(() => + window.document.documentElement.getAttribute("data-nextjs-router") + ); + + expect(dataNextJsRouter).toEqual("app"); + + const locator = page.getByRole("heading", { name: "teams" }); + + await expect(locator).toBeVisible(); + }); +}); + test.describe("Teams - NonOrg", () => { test.afterEach(({ users }) => users.deleteAll()); diff --git a/turbo.json b/turbo.json index e7f02741413c31..dfd687535db9bf 100644 --- a/turbo.json +++ b/turbo.json @@ -211,6 +211,7 @@ "APP_ROUTER_SETTINGS_TEAMS_ENABLED", "APP_ROUTER_WORKFLOWS_ENABLED", "APP_ROUTER_VIDEO_ENABLED", + "APP_ROUTER_TEAMS_ENABLED", "APP_USER_NAME", "BASECAMP3_CLIENT_ID", "BASECAMP3_CLIENT_SECRET", From 574a4a847d2d49a03e774726fac48443b241621b Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Wed, 3 Jan 2024 18:35:16 -0500 Subject: [PATCH 30/42] chore: Allow labeler action to use workflow_dispatch (#12993) * chore: Update team labeler to workflow_dispatch * Fixed conflict --- .github/workflows/labeler.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 44978d72d6e11d..e9074b895eacfe 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,7 +1,7 @@ name: "Pull Request Labeler" on: - - pull_request_target - - workflow_call + pull_request_target: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true From 6a1325867e0255890a6a4d9870f46969986eb691 Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Wed, 3 Jan 2024 18:18:42 -0700 Subject: [PATCH 31/42] fix: Fix all TS warnings (fix-tsWarnings) (#12139) Co-authored-by: gitstart-calcom Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: Peer Richelsen --- apps/web/lib/withNonce.tsx | 4 +- apps/web/pages/_app.tsx | 1 + apps/web/pages/_document.tsx | 3 +- apps/web/pages/auth/oauth2/authorize.tsx | 1 + apps/web/pages/event-types/index.tsx | 7 +--- apps/web/playwright/payment-apps.e2e.ts | 42 +++++++------------ apps/web/playwright/reschedule.e2e.ts | 1 + .../test/lib/handleChildrenEventTypes.test.ts | 5 +-- .../_utils/oauth/parseRefreshTokenResponse.ts | 1 + .../_utils/oauth/refreshOAuthTokens.ts | 1 + .../components/EventTypeAppCardInterface.tsx | 2 + .../basecamp3/lib/CalendarService.ts | 1 + packages/app-store/basecamp3/trpc/_router.ts | 1 + .../basecamp3/trpc/projectMutation.handler.ts | 6 ++- .../components/EventTypeAppCardInterface.tsx | 1 + .../app-store/paypal/lib/PaymentService.ts | 19 ++------- packages/lib/getEventTypeById.ts | 18 -------- packages/lib/hasEditPermissionForUser.ts | 2 - packages/lib/payment/getBooking.ts | 1 + .../loggedInViewer/integrations.handler.ts | 1 - .../team/listTeamAvailability.handler.ts | 2 +- .../server/routers/viewer/oAuth/_router.tsx | 2 +- 22 files changed, 42 insertions(+), 80 deletions(-) diff --git a/apps/web/lib/withNonce.tsx b/apps/web/lib/withNonce.tsx index 1649d6fab97729..08e24bde0eb85b 100644 --- a/apps/web/lib/withNonce.tsx +++ b/apps/web/lib/withNonce.tsx @@ -2,7 +2,7 @@ import type { GetServerSideProps } from "next"; import { csp } from "@lib/csp"; -export type WithNonceProps> = T & { +export type WithNonceProps> = T & { nonce?: string; }; @@ -11,7 +11,7 @@ export type WithNonceProps> = T & { * Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages * There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag. */ -export default function withNonce>( +export default function withNonce>( getServerSideProps: GetServerSideProps ): GetServerSideProps> { return async (context) => { diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 89d53107ecb97b..511bd9eb954f71 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -28,6 +28,7 @@ MyApp.getInitialProps = async (ctx: AppContextType) => { if (req) { const { getLocale } = await import("@calcom/features/auth/lib/getLocale"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any newLocale = await getLocale(req as IncomingMessage & { cookies: Record }); } else if (typeof window !== "undefined" && window.calNewLocale) { newLocale = window.calNewLocale; diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx index f1755445ed0343..2296b50224e4ef 100644 --- a/apps/web/pages/_document.tsx +++ b/apps/web/pages/_document.tsx @@ -32,7 +32,8 @@ class MyDocument extends Document { const newLocale = ctx.req && getLocaleModule - ? await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record }) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + await getLocaleModule.getLocale(ctx.req as IncomingMessage & { cookies: Record }) : "en"; const asPath = ctx.asPath || ""; diff --git a/apps/web/pages/auth/oauth2/authorize.tsx b/apps/web/pages/auth/oauth2/authorize.tsx index c73949584658a8..20061199c37bb5 100644 --- a/apps/web/pages/auth/oauth2/authorize.tsx +++ b/apps/web/pages/auth/oauth2/authorize.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/exhaustive-deps */ import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 151221c8df1140..b068c7d6874032 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -907,6 +907,7 @@ const EventTypesPage = () => { const searchParams = useCompatSearchParams(); const { open } = useIntercom(); const { data: user } = useMeQuery(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [showProfileBanner, setShowProfileBanner] = useState(false); const orgBranding = useOrgBranding(); const routerQuery = useRouterQuery(); @@ -919,12 +920,6 @@ const EventTypesPage = () => { staleTime: 1 * 60 * 60 * 1000, }); - function closeBanner() { - setShowProfileBanner(false); - document.cookie = `calcom-profile-banner=1;max-age=${60 * 60 * 24 * 90}`; // 3 months - showToast(t("we_wont_show_again"), "success"); - } - useEffect(() => { if (searchParams?.get("openIntercom") === "true") { open(); diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts index 998fa508888eb1..4aca2efb227304 100644 --- a/apps/web/playwright/payment-apps.e2e.ts +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -13,9 +13,7 @@ test.describe("Payment app", () => { const user = await users.create(); await user.apiLogin(); const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { type: "alby_payment", @@ -30,7 +28,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); await page.getByPlaceholder("Price").click(); @@ -38,7 +36,7 @@ test.describe("Payment app", () => { await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click(); await page.getByTestId("update-eventtype").click(); - await page.goto(`${user.username}/${paymentEvent.slug}`); + await page.goto(`${user.username}/${paymentEvent?.slug}`); // expect 200 sats to be displayed in page expect(await page.locator("text=200 sats").first()).toBeTruthy(); @@ -55,9 +53,7 @@ test.describe("Payment app", () => { const user = await users.create(); await user.apiLogin(); const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { type: "stripe_payment", @@ -75,7 +71,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); await page.getByTestId("stripe-currency-select").click(); await page.getByTestId("select-option-usd").click(); @@ -84,7 +80,7 @@ test.describe("Payment app", () => { await page.getByTestId("stripe-price-input").fill("350"); await page.getByTestId("update-eventtype").click(); - await page.goto(`${user.username}/${paymentEvent.slug}`); + await page.goto(`${user.username}/${paymentEvent?.slug}`); // expect 200 sats to be displayed in page expect(await page.locator("text=350").first()).toBeTruthy(); @@ -101,9 +97,7 @@ test.describe("Payment app", () => { const user = await users.create(); await user.apiLogin(); const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { type: "paypal_payment", @@ -116,7 +110,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); @@ -131,7 +125,7 @@ test.describe("Payment app", () => { await page.getByText("$MXNCurrencyMexican pesoPayment option").click(); await page.getByTestId("update-eventtype").click(); - await page.goto(`${user.username}/${paymentEvent.slug}`); + await page.goto(`${user.username}/${paymentEvent?.slug}`); // expect 150 to be displayed in page expect(await page.locator("text=MX$150.00").first()).toBeTruthy(); @@ -149,9 +143,7 @@ test.describe("Payment app", () => { const user = await users.create(); await user.apiLogin(); const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { type: "alby_payment", @@ -160,7 +152,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); @@ -177,9 +169,7 @@ test.describe("Payment app", () => { const user = await users.create(); await user.apiLogin(); const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { type: "paypal_payment", @@ -188,7 +178,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); @@ -211,9 +201,7 @@ test.describe("Payment app", () => { await user.apiLogin(); // Any event should work here const paymentEvent = user.eventTypes.find((item) => item.slug === "paid"); - if (!paymentEvent) { - throw new Error("No payment event found"); - } + expect(paymentEvent).not.toBeNull(); await prisma.credential.create({ data: { @@ -225,7 +213,7 @@ test.describe("Payment app", () => { }, }); - await page.goto(`event-types/${paymentEvent.id}?tabName=apps`); + await page.goto(`event-types/${paymentEvent?.id}?tabName=apps`); await page.locator("#event-type-form").getByRole("switch").click(); // make sure Tracking ID is displayed diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 3c31cb56783418..bdb1edda01fc86 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -252,6 +252,7 @@ test.describe("Reschedule Tests", async () => { let firstOfNextMonth = dayjs().add(1, "month").startOf("month"); // find first available slot of next month (available monday-friday) + // eslint-disable-next-line playwright/no-conditional-in-test while (firstOfNextMonth.day() < 1 || firstOfNextMonth.day() > 5) { firstOfNextMonth = firstOfNextMonth.add(1, "day"); } diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 84d417733fcbcd..d167dfa4ae18f7 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; import type { EventType } from "@prisma/client"; @@ -97,7 +98,6 @@ describe("handleChildrenEventTypes", () => { it("Adds new users", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - // eslint-disable-next-line const { schedulingType, id, @@ -141,7 +141,6 @@ describe("handleChildrenEventTypes", () => { it("Updates old users", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - // eslint-disable-next-line const { schedulingType, id, @@ -237,7 +236,6 @@ describe("handleChildrenEventTypes", () => { it("Deletes existent event types for new users added", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - // eslint-disable-next-line const { schedulingType, id, @@ -282,7 +280,6 @@ describe("handleChildrenEventTypes", () => { it("Deletes existent event types for old users updated", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - // eslint-disable-next-line const { schedulingType, id, diff --git a/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts b/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts index fc0d7fc21d56e1..958205707a332a 100644 --- a/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts +++ b/packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts @@ -14,6 +14,7 @@ export type ParseRefreshTokenResponse = | z.infer | z.infer; +// eslint-disable-next-line @typescript-eslint/no-explicit-any const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => { let refreshTokenResponse; const credentialSyncingEnabled = diff --git a/packages/app-store/_utils/oauth/refreshOAuthTokens.ts b/packages/app-store/_utils/oauth/refreshOAuthTokens.ts index b667154c90752f..999ada5245d7f3 100644 --- a/packages/app-store/_utils/oauth/refreshOAuthTokens.ts +++ b/packages/app-store/_utils/oauth/refreshOAuthTokens.ts @@ -1,5 +1,6 @@ import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => { // Check that app syncing is enabled and that the credential belongs to a user if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) { diff --git a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx index cc81f0237e75c2..cbab47df358ae2 100644 --- a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx @@ -18,9 +18,11 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ useEffect(() => { setSelectedProject({ value: data?.projects.currentProject, + // eslint-disable-next-line @typescript-eslint/no-explicit-any label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name, }); setProjects( + // eslint-disable-next-line @typescript-eslint/no-explicit-any data?.projects?.map((project: any) => { return { value: project.id, diff --git a/packages/app-store/basecamp3/lib/CalendarService.ts b/packages/app-store/basecamp3/lib/CalendarService.ts index 783373ac96081a..46531a6dee8402 100644 --- a/packages/app-store/basecamp3/lib/CalendarService.ts +++ b/packages/app-store/basecamp3/lib/CalendarService.ts @@ -59,6 +59,7 @@ export default class BasecampCalendarService implements Calendar { constructor(credential: CredentialPayload) { this.integrationName = "basecamp3"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any getAppKeysFromSlug("basecamp3").then(({ user_agent }: any) => { this.userAgent = user_agent as string; }); diff --git a/packages/app-store/basecamp3/trpc/_router.ts b/packages/app-store/basecamp3/trpc/_router.ts index ce2122375f0573..e3f9d7159524ea 100644 --- a/packages/app-store/basecamp3/trpc/_router.ts +++ b/packages/app-store/basecamp3/trpc/_router.ts @@ -3,6 +3,7 @@ import { router } from "@calcom/trpc/server/trpc"; import { ZProjectMutationInputSchema } from "./projectMutation.schema"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any const UNSTABLE_HANDLER_CACHE: any = {}; const appBasecamp3 = router({ diff --git a/packages/app-store/basecamp3/trpc/projectMutation.handler.ts b/packages/app-store/basecamp3/trpc/projectMutation.handler.ts index 6a9ffaeb9e3e41..5f7afc2aa258ce 100644 --- a/packages/app-store/basecamp3/trpc/projectMutation.handler.ts +++ b/packages/app-store/basecamp3/trpc/projectMutation.handler.ts @@ -16,6 +16,10 @@ interface ProjectMutationHandlerOptions { input: TProjectMutationInputSchema; } +interface IDock { + id: number; + name: string; +} export const projectMutationHandler = async ({ ctx, input }: ProjectMutationHandlerOptions) => { const { user_agent } = await getAppKeysFromSlug("basecamp3"); const { user, prisma } = ctx; @@ -48,7 +52,7 @@ export const projectMutationHandler = async ({ ctx, input }: ProjectMutationHand } ); const scheduleJson = await scheduleResponse.json(); - const scheduleId = scheduleJson.dock.find((dock: any) => dock.name === "schedule").id; + const scheduleId = scheduleJson.dock.find((dock: IDock) => dock.name === "schedule").id; await prisma.credential.update({ where: { id: credential.id }, data: { key: { ...credentialKey, projectId: Number(projectId), scheduleId } }, diff --git a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx index 008d875cb67615..a51e7a91d516e2 100644 --- a/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx +++ b/packages/app-store/paypal/components/EventTypeAppCardInterface.tsx @@ -56,6 +56,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ setAppData("paymentOption", paymentOptions[0].value); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/packages/app-store/paypal/lib/PaymentService.ts b/packages/app-store/paypal/lib/PaymentService.ts index 9ad68c68460cc3..ce3b1862bb7a3c 100644 --- a/packages/app-store/paypal/lib/PaymentService.ts +++ b/packages/app-store/paypal/lib/PaymentService.ts @@ -8,7 +8,6 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; -import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { paymentOptionEnum } from "../zod"; @@ -179,10 +178,7 @@ export class PaymentService implements IAbstractPaymentService { throw new Error("Paypal: Payment method could not be collected"); } } - chargeCard( - payment: Pick, - bookingId: number - ): Promise { + chargeCard(): Promise { throw new Error("Method not implemented."); } getPaymentPaidStatus(): Promise { @@ -191,19 +187,10 @@ export class PaymentService implements IAbstractPaymentService { getPaymentDetails(): Promise { throw new Error("Method not implemented."); } - afterPayment( - event: CalendarEvent, - booking: { - user: { email: string | null; name: string | null; timeZone: string } | null; - id: number; - startTime: { toISOString: () => string }; - uid: string; - }, - paymentData: Payment - ): Promise { + afterPayment(): Promise { return Promise.resolve(); } - deletePayment(paymentId: number): Promise { + deletePayment(): Promise { return Promise.resolve(false); } diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 8df07f78686564..0632aed00da0bb 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -1,7 +1,6 @@ import { Prisma } from "@prisma/client"; import { getLocationGroupedOptions } from "@calcom/app-store/server"; -import type { StripeData } from "@calcom/app-store/stripepayment/lib/server"; import { getEventTypeAppData } from "@calcom/app-store/utils"; import type { LocationObject } from "@calcom/core/location"; import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields"; @@ -9,7 +8,6 @@ import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@cal import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { PrismaClient } from "@calcom/prisma"; -import type { Credential } from "@calcom/prisma/client"; import { SchedulingType, MembershipRole } from "@calcom/prisma/enums"; import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -397,19 +395,3 @@ export default async function getEventTypeById({ }; return finalObj; } - -const getStripeCurrency = (stripeMetadata: { currency: string }, credentials: Credential[]) => { - // Favor the currency from the metadata as EventType.currency was not always set and should be deprecated - if (stripeMetadata.currency) { - return stripeMetadata.currency; - } - - // Legacy support for EventType.currency - const stripeCredential = credentials.find((integration) => integration.type === "stripe_payment"); - if (stripeCredential) { - return (stripeCredential.key as unknown as StripeData)?.default_currency || "usd"; - } - - // Fallback to USD but should not happen - return "usd"; -}; diff --git a/packages/lib/hasEditPermissionForUser.ts b/packages/lib/hasEditPermissionForUser.ts index 87d9e13e2f8b73..c4adf2de4069ba 100644 --- a/packages/lib/hasEditPermissionForUser.ts +++ b/packages/lib/hasEditPermissionForUser.ts @@ -2,8 +2,6 @@ import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; -const ROLES_WITH_EDIT_PERMISSION = [MembershipRole.ADMIN, MembershipRole.OWNER] as MembershipRole[]; - type InputOptions = { ctx: { user: NonNullable; diff --git a/packages/lib/payment/getBooking.ts b/packages/lib/payment/getBooking.ts index ec4e97c182f4d9..af0ea785ec413d 100644 --- a/packages/lib/payment/getBooking.ts +++ b/packages/lib/payment/getBooking.ts @@ -97,6 +97,7 @@ export async function getBooking(bookingId: number) { }), organizer: { email: user.email, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion name: user.name!, timeZone: user.timeZone, timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat), diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts index 70ea87aed55ab3..7976abee835400 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.handler.ts @@ -51,7 +51,6 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) = extendsFeature, teamId, sortByMostPopular, - categories, appId, } = input; let credentials = await getUsersCredentials(user.id); diff --git a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts index 58c6132ba8f136..6d6d709ffe33be 100644 --- a/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/team/listTeamAvailability.handler.ts @@ -187,7 +187,7 @@ export const listTeamAvailabilityHandler = async ({ ctx, input }: GetOptions) => let nextCursor: typeof cursor | undefined = undefined; if (teamMembers && teamMembers.length > limit) { const nextItem = teamMembers.pop(); - nextCursor = nextItem!.id; + nextCursor = nextItem?.id; } const dateFrom = dayjs(input.startDate).tz(input.loggedInUsersTz).subtract(1, "day"); diff --git a/packages/trpc/server/routers/viewer/oAuth/_router.tsx b/packages/trpc/server/routers/viewer/oAuth/_router.tsx index 24a5fb0be77a9e..b2936ed0a4dc64 100644 --- a/packages/trpc/server/routers/viewer/oAuth/_router.tsx +++ b/packages/trpc/server/routers/viewer/oAuth/_router.tsx @@ -14,7 +14,7 @@ type OAuthRouterHandlerCache = { const UNSTABLE_HANDLER_CACHE: OAuthRouterHandlerCache = {}; export const oAuthRouter = router({ - getClient: authedProcedure.input(ZGetClientInputSchema).query(async ({ ctx, input }) => { + getClient: authedProcedure.input(ZGetClientInputSchema).query(async ({ input }) => { if (!UNSTABLE_HANDLER_CACHE.getClient) { UNSTABLE_HANDLER_CACHE.getClient = await import("./getClient.handler").then( (mod) => mod.getClientHandler From de1c9d01cd3c3a105f1d8a677aa24c3a4b64edf9 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 4 Jan 2024 08:33:51 +0530 Subject: [PATCH 32/42] feat: orgMigration - Support moving users as an option when moving a team (#12917) * Move orgMigration routes to app to allow them to be tested as they are here to stay for longer tim * move to Form everywhere and fix session reading --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- apps/web/lib/orgMigration.test.ts | 1491 +++++++++++++++++ apps/web/lib/orgMigration.ts | 817 +++++++++ .../pages/api/orgMigration/moveTeamToOrg.ts | 73 + .../pages/api/orgMigration/moveUserToOrg.ts | 75 + .../api/orgMigration/removeTeamFromOrg.ts | 63 + .../api/orgMigration/removeUserFromOrg.ts | 59 + .../orgMigrations/_OrgMigrationLayout.tsx | 33 + .../admin/orgMigrations/moveTeamToOrg.tsx | 175 ++ .../admin/orgMigrations/moveUserToOrg.tsx | 213 +++ .../admin/orgMigrations/removeTeamFromOrg.tsx | 142 ++ .../admin/orgMigrations/removeUserFromOrg.tsx | 137 ++ packages/prisma/zod-utils.ts | 3 + 12 files changed, 3281 insertions(+) create mode 100644 apps/web/lib/orgMigration.test.ts create mode 100644 apps/web/lib/orgMigration.ts create mode 100644 apps/web/pages/api/orgMigration/moveTeamToOrg.ts create mode 100644 apps/web/pages/api/orgMigration/moveUserToOrg.ts create mode 100644 apps/web/pages/api/orgMigration/removeTeamFromOrg.ts create mode 100644 apps/web/pages/api/orgMigration/removeUserFromOrg.ts create mode 100644 apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx diff --git a/apps/web/lib/orgMigration.test.ts b/apps/web/lib/orgMigration.test.ts new file mode 100644 index 00000000000000..cfe4c78b74b9c9 --- /dev/null +++ b/apps/web/lib/orgMigration.test.ts @@ -0,0 +1,1491 @@ +import prismock from "../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; + +import type { MembershipRole, Prisma } from "@calcom/prisma/client"; +import { RedirectType } from "@calcom/prisma/enums"; +import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { moveTeamToOrg, moveUserToOrg, removeTeamFromOrg, removeUserFromOrg } from "./orgMigration"; + +describe("orgMigration", () => { + describe("moveUserToOrg", () => { + describe("when user email does not match orgAutoAcceptEmail", () => { + it(`should migrate a user to become a part of an organization with ADMIN role + - username in the organization should be automatically derived from email when it isn't passed`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const team1 = await createTeamOutsideOrg({ + name: "Team-1", + slug: "team-1", + }); + + // Make userToMigrate part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await expectTeamToBeNotPartOfAnyOrganization({ + teamId: team1.id, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.expectedUsernameInOrg, + orgSlug: data.targetOrg.slug, + }); + }); + + it("should migrate a user to become a part of an organization(which has slug set) with MEMBER role", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + id: 1, + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + }); + + await moveUserToOrg({ + user: { + id: dbOrg.id, + }, + targetOrg: { + id: data.targetOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: data.targetOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it(`should migrate a user to become a part of an organization(which has no slug but requestedSlug) with MEMBER role + 1. Should set the slug as requestedSlug for the organization(so that the redirect doesnt take to an unpublished organization page)`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + requestedSlug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: "user-1@example.com", + username: "user-1", + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + metadata: { + requestedSlug: data.targetOrg.requestedSlug, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + const organization = await prismock.team.findUnique({ + where: { + id: dbOrg.id, + }, + }); + + expect(organization?.slug).toBe(data.targetOrg.requestedSlug); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it("should migrate a user along with its teams(without other members)", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + teams: [ + { + name: "Team 100", + slug: "team-100", + }, + { + name: "Team 101", + slug: "team-101", + }, + ], + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + userPartOfTeam1: { + username: "user-2", + email: "user-2@example.com", + }, + userPartOfTeam2: { + username: "user-3", + email: "user-3@example.com", + }, + }; + + // Create user to migrate + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + // Create another user that would be part of team-1 along with userToMigrate + const userPartOfTeam1 = await createUserOutsideOrg({ + email: data.userPartOfTeam1.email, + username: data.userPartOfTeam1.username, + }); + + // Create another user that would be part of team-2 along with userToMigrate + const userPartOfTeam2 = await createUserOutsideOrg({ + email: data.userPartOfTeam2.email, + username: data.userPartOfTeam2.username, + }); + + const team1 = await createTeamOutsideOrg({ + name: data.userToMigrate.teams[0].name, + slug: data.userToMigrate.teams[0].slug, + }); + + const team2 = await createTeamOutsideOrg({ + name: data.userToMigrate.teams[1].name, + slug: data.userToMigrate.teams[1].slug, + }); + + // Make userToMigrate part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + // Make userToMigrate part of team-2 + await addMemberShipOfUserWithTeam({ + teamId: team2.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + // Make userPartofTeam1 part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: userPartOfTeam1.id, + role: "MEMBER", + accepted: true, + }); + + // Make userPartofTeam2 part of team2 + await addMemberShipOfUserWithTeam({ + teamId: team2.id, + userId: userPartOfTeam2.id, + role: "MEMBER", + accepted: true, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: true, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: team1.id, + orgId: dbOrg.id, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: team2.id, + orgId: dbOrg.id, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: userPartOfTeam1.id, + orgId: dbOrg.id, + username: data.userPartOfTeam1.username, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: userPartOfTeam2.id, + orgId: dbOrg.id, + username: data.userPartOfTeam2.username, + }); + }); + + it(`should migrate a user to become a part of an organization + - username in the organization should same as provided to moveUserToOrg`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + usernameInOrgThatWeWant: "user-1-in-org", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameInOrgThatWeWant, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameInOrgThatWeWant, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.usernameInOrgThatWeWant, + orgSlug: data.targetOrg.slug, + }); + }); + + it(`should be able to re-migrate an already migrated user fixing things as we do it + - Redirect should correctly determine the nonOrgUsername so that on repeat migrations, the original user link doesn't break`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + usernameWeWantInOrg: "user-1-in-org", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameWeWantInOrg, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameWeWantInOrg, + // membership role should be updated + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameWeWantInOrg, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameWeWantInOrg, + // membership role should be updated + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.usernameWeWantInOrg, + orgSlug: data.targetOrg.slug, + }); + + console.log(await prismock.tempOrgRedirect.findMany({})); + }); + + describe("Failures handling", () => { + it("migration should fail if the username already exists in the organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + // User with same username exists outside the org as well as inside the org + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError("already exists"); + }); + + it("should fail the migration if the target org doesn't exist", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + // User with same username exists outside the org as well as inside the org + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: 1001, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/Org .* not found/); + }); + + it("should fail the migration if the target teamId is not an organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + // Create a team instead of an organization + const dbOrgWhichIsActuallyATeam = await createTeamOutsideOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: dbOrgWhichIsActuallyATeam.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/is not an Org/); + }); + + it("should fail the migration if the user is part of any other organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbOrgOther = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrgOther.id + ); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: dbOrg.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/already a part of different organization/); + }); + }); + }); + + describe("when user email matches orgAutoAcceptEmail", () => { + const orgMetadata = { + orgAutoAcceptEmail: "org1.com", + } as const; + + it(`should migrate a user to become a part of an organization with ADMIN role`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.expectedUsernameInOrg, + orgSlug: data.targetOrg.slug, + }); + }); + + it("should migrate a user to become a part of an organization(which has slug set) with MEMBER role", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it(`should migrate a user to become a part of an organization(which has no slug but requestedSlug) with MEMBER role + 1. Should set the slug as requestedSlug for the organization(so that the redirect doesnt take to an unpublished organization page)`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + requestedSlug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + metadata: { + requestedSlug: data.targetOrg.requestedSlug, + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + const organization = await prismock.team.findUnique({ + where: { + id: dbOrg.id, + }, + }); + + expect(organization?.slug).toBe(data.targetOrg.requestedSlug); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + }); + }); + + describe("moveTeamToOrg", () => { + it(`should migrate a team to become a part of an organization`, async () => { + const data = { + teamToMigrate: { + id: 1, + name: "Team 1", + slug: "team1", + }, + targetOrg: { + id: 2, + name: "Org 1", + slug: "org1", + }, + }; + + await prismock.team.create({ + data: { + id: data.teamToMigrate.id, + slug: data.teamToMigrate.slug, + name: data.teamToMigrate.name, + }, + }); + + await prismock.team.create({ + data: { + id: data.targetOrg.id, + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + isOrganization: true, + }, + }, + }); + + await moveTeamToOrg({ + teamId: data.teamToMigrate.id, + targetOrgId: data.targetOrg.id, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: data.teamToMigrate.id, + orgId: data.targetOrg.id, + }); + + expectTeamRedirectToBeEnabled({ + from: { + teamSlug: data.teamToMigrate.slug, + }, + to: data.teamToMigrate.slug, + orgSlug: data.targetOrg.slug, + }); + }); + it.todo("should migrate a team with members"); + it.todo("Try migrating an already migrated team"); + }); + + describe("removeUserFromOrg", () => { + it(`should remove a user from an organization but he should remain in team`, async () => { + const data = { + userToUnmigrate: { + username: "user1-in-org1", + email: "user-1@example.com", + usernameBeforeMovingToOrg: "user1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + }); + + const dbTeamOutsideOrg = await createTeamOutsideOrg({ + slug: "team-1", + name: "Team 1", + }); + + const dbUser = await createUserInsideTheOrg( + { + email: data.userToUnmigrate.email, + username: data.userToUnmigrate.username, + metadata: { + migratedToOrgFrom: { + username: data.userToUnmigrate.usernameBeforeMovingToOrg, + }, + }, + }, + dbOrg.id + ); + + await addMemberShipOfUserWithOrg({ + userId: dbUser.id, + teamId: dbTeamOutsideOrg.id, + role: "MEMBER", + accepted: true, + }); + + await addMemberShipOfUserWithTeam({ + userId: dbUser.id, + teamId: dbOrg.id, + role: data.membershipWeWant.role, + accepted: true, + }); + + const userToUnmigrate = await prismock.user.findUnique({ + where: { + id: dbUser.id, + }, + include: { + organization: true, + }, + }); + + if (!userToUnmigrate?.organizationId || !userToUnmigrate.organization) { + throw new Error( + `Couldn't setup user to unmigrate properly userToUnMigrate: ${{ + organizationId: userToUnmigrate?.organizationId, + organization: !!userToUnmigrate?.organization, + }}` + ); + } + + await removeUserFromOrg({ + userId: dbUser.id, + targetOrgId: dbOrg.id, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: dbUser.id, + orgId: dbOrg.id, + username: data.userToUnmigrate.usernameBeforeMovingToOrg, + }); + + await expectUserToBeAPartOfTeam({ + userId: dbUser.id, + teamId: dbTeamOutsideOrg.id, + expectedMembership: { + role: "MEMBER", + accepted: true, + }, + }); + + expectUserRedirectToBeNotEnabled({ + from: { + username: data.userToUnmigrate.username, + }, + }); + }); + }); + + describe("removeTeamFromOrg", () => { + it(`should remove a team from an organization`, async () => { + const data = { + teamToUnmigrate: { + name: "Team 1", + slug: "team1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + }; + + const targetOrg = await prismock.team.create({ + data: { + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + isOrganization: true, + }, + }, + }); + + const { id: teamToUnMigrateId } = await prismock.team.create({ + data: { + slug: data.teamToUnmigrate.slug, + name: data.teamToUnmigrate.name, + parent: { + connect: { + id: targetOrg.id, + }, + }, + }, + }); + + const teamToUnmigrate = await prismock.team.findUnique({ + where: { + id: teamToUnMigrateId, + }, + include: { + parent: true, + }, + }); + + if (!teamToUnmigrate?.parent || !teamToUnmigrate.parentId) { + throw new Error(`Couldn't setup team to unmigrate properly ID:${teamToUnMigrateId}`); + } + + await removeTeamFromOrg({ + teamId: teamToUnMigrateId, + targetOrgId: targetOrg.id, + }); + + await expectTeamToBeNotPartOfAnyOrganization({ + teamId: teamToUnMigrateId, + }); + + expectTeamRedirectToBeNotEnabled({ + from: { + teamSlug: data.teamToUnmigrate.slug, + }, + to: data.teamToUnmigrate.slug, + orgSlug: data.targetOrg.slug, + }); + }); + }); +}); + +async function expectUserToBeAPartOfOrg({ + userId, + orgId, + expectedMembership, + usernameInOrg, +}: { + userId: number; + orgId: number; + usernameInOrg: string; + expectedMembership: { + role: MembershipRole; + accepted: boolean; + }; +}) { + const migratedUser = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!migratedUser) { + throw new Error(`User with id ${userId} does not exist`); + } + + expect(migratedUser.username).toBe(usernameInOrg); + expect(migratedUser.organizationId).toBe(orgId); + + const membership = migratedUser.teams.find( + (membership) => membership.teamId === orgId && membership.userId === userId + ); + + expect(membership).not.toBeNull(); + if (!membership) { + throw new Error(`User with id ${userId} is not a part of org with id ${orgId}`); + } + expect(membership.role).toBe(expectedMembership.role); + expect(membership.accepted).toBe(expectedMembership.accepted); +} + +async function expectUserToBeAPartOfTeam({ + userId, + teamId, + expectedMembership, +}: { + userId: number; + teamId: number; + expectedMembership: { + role: MembershipRole; + accepted: boolean; + }; +}) { + const user = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!user) { + throw new Error(`User with id ${userId} does not exist`); + } + + const membership = user.teams.find( + (membership) => membership.teamId === teamId && membership.userId === userId + ); + + expect(membership).not.toBeNull(); + if (!membership) { + throw new Error(`User with id ${userId} is not a part of team with id ${teamId}`); + } + expect(membership.role).toBe(expectedMembership.role); + expect(membership.accepted).toBe(expectedMembership.accepted); +} + +async function expectUserToBeNotAPartOfTheOrg({ + userId, + orgId, + username, +}: { + userId: number; + orgId: number; + username: string; +}) { + const user = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!user) { + throw new Error(`User with id ${userId} does not exist`); + } + + expect(user.username).toBe(username); + expect(user.organizationId).toBe(null); + + const membership = user.teams.find( + (membership) => membership.teamId === orgId && membership.userId === userId + ); + + expect(membership).toBeUndefined(); +} + +async function expectTeamToBeAPartOfOrg({ teamId, orgId }: { teamId: number; orgId: number }) { + const migratedTeam = await prismock.team.findUnique({ + where: { + id: teamId, + }, + }); + if (!migratedTeam) { + throw new Error(`Team with id ${teamId} does not exist`); + } + + expect(migratedTeam.parentId).toBe(orgId); +} + +async function expectTeamToBeNotPartOfAnyOrganization({ teamId }: { teamId: number }) { + const team = await prismock.team.findUnique({ + where: { + id: teamId, + }, + }); + if (!team) { + throw new Error(`Team with id ${teamId} does not exist`); + } + + expect(team.parentId).toBe(null); +} + +async function expectUserRedirectToBeEnabled({ + from, + to, + orgSlug, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType: RedirectType.User, + }); +} + +async function expectTeamRedirectToBeEnabled({ + from, + to, + orgSlug, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType: RedirectType.Team, + }); +} + +async function expectUserRedirectToBeNotEnabled({ + from, +}: { + from: { username: string } | { teamSlug: string }; +}) { + expectRedirectToBeNotEnabled({ + from, + }); +} + +async function expectTeamRedirectToBeNotEnabled({ + from, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeNotEnabled({ + from, + }); +} + +async function expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; + redirectType: RedirectType; +}) { + let tempOrgRedirectWhere = null; + let tempOrgRedirectThatShouldNotExistWhere = null; + if ("username" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.username, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + + // Normally with user migration `from.username != to` + if (from.username !== to) { + // There must not be a redirect setup from=To to something else as that would cause double redirection + tempOrgRedirectThatShouldNotExistWhere = { + from_type_fromOrgId: { + from: to, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + } + } else if ("teamSlug" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.teamSlug, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + + if (from.teamSlug !== to) { + // There must not be a redirect setup from=To to something else as that would cause double redirection + tempOrgRedirectThatShouldNotExistWhere = { + from_type_fromOrgId: { + from: to, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + } + } else { + throw new Error("Atleast one of userId or teamId should be present in from"); + } + const redirect = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectWhere, + }); + + if (tempOrgRedirectThatShouldNotExistWhere) { + const redirectThatShouldntBeThere = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectThatShouldNotExistWhere, + }); + expect(redirectThatShouldntBeThere).toBeNull(); + } + + expect(redirect).not.toBeNull(); + expect(redirect?.toUrl).toBe(`http://${orgSlug}.cal.local:3000/${to}`); + if (!redirect) { + throw new Error(`Redirect doesn't exist for ${JSON.stringify(tempOrgRedirectWhere)}`); + } + expect(redirect.type).toBe(redirectType); +} + +async function expectRedirectToBeNotEnabled({ from }: { from: { username: string } | { teamSlug: string } }) { + let tempOrgRedirectWhere = null; + if ("username" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.username, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + } else if ("teamSlug" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.teamSlug, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + } else { + throw new Error("Atleast one of userId or teamId should be present in from"); + } + const redirect = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectWhere, + }); + expect(redirect).toBeNull(); +} + +async function createOrg( + data: Omit & { + metadata?: z.infer; + } +) { + return await prismock.team.create({ + data: { + ...data, + metadata: { + ...(data.metadata || {}), + isOrganization: true, + }, + }, + }); +} + +async function createTeamOutsideOrg( + data: Omit & { + metadata?: z.infer; + } +) { + return await prismock.team.create({ + data: { + ...data, + parentId: null, + metadata: { + ...(data.metadata || {}), + isOrganization: false, + }, + }, + }); +} + +async function createUserOutsideOrg(data: Omit) { + return await prismock.user.create({ + data: { + ...data, + organizationId: null, + }, + }); +} + +async function createUserInsideTheOrg( + data: Omit, + orgId: number +) { + const org = await prismock.team.findUnique({ + where: { + id: orgId, + }, + }); + if (!org) { + throw new Error(`Org with id ${orgId} does not exist`); + } + return await prismock.user.create({ + data: { + ...data, + organization: { + connect: { + id: orgId, + }, + }, + }, + }); +} + +async function addMemberShipOfUserWithTeam({ + teamId, + userId, + role, + accepted, +}: { + teamId: number; + userId: number; + role: MembershipRole; + accepted: boolean; +}) { + await prismock.membership.create({ + data: { + role, + accepted, + team: { + connect: { + id: teamId, + }, + }, + user: { + connect: { + id: userId, + }, + }, + }, + }); + + const membership = await prismock.membership.findUnique({ + where: { + userId_teamId: { + teamId, + userId, + }, + }, + }); + if (!membership) { + throw new Error(`Membership between teamId ${teamId} and userId ${userId} couldn't be created`); + } +} + +const addMemberShipOfUserWithOrg = addMemberShipOfUserWithTeam; diff --git a/apps/web/lib/orgMigration.ts b/apps/web/lib/orgMigration.ts new file mode 100644 index 00000000000000..5a8b4d1fdf19ac --- /dev/null +++ b/apps/web/lib/orgMigration.ts @@ -0,0 +1,817 @@ +import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { Team, User } from "@calcom/prisma/client"; +import { RedirectType } from "@calcom/prisma/client"; +import { Prisma } from "@calcom/prisma/client"; +import type { MembershipRole } from "@calcom/prisma/enums"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +const log = logger.getSubLogger({ prefix: ["orgMigration"] }); + +type UserMetadata = { + migratedToOrgFrom?: { + username: string; + reverted: boolean; + revertTime: string; + lastMigrationTime: string; + }; +}; + +/** + * Make sure that the migration is idempotent + */ +export async function moveUserToOrg({ + user: { id: userId, userName: userName }, + targetOrg: { + id: targetOrgId, + username: targetOrgUsername, + membership: { role: targetOrgRole, accepted: targetOrgMembershipAccepted = true }, + }, + shouldMoveTeams, +}: { + user: { id?: number; userName?: string }; + targetOrg: { + id: number; + username?: string; + membership: { role: MembershipRole; accepted?: boolean }; + }; + shouldMoveTeams: boolean; +}) { + assertUserIdOrUserName(userId, userName); + const team = await getTeamOrThrowError(targetOrgId); + + const teamMetadata = teamMetadataSchema.parse(team?.metadata); + + if (!teamMetadata?.isOrganization) { + throw new Error(`Team with ID:${targetOrgId} is not an Org`); + } + + const targetOrganization = { + ...team, + metadata: teamMetadata, + }; + const userToMoveToOrg = await getUniqueUserThatDoesntBelongToOrg(userName, userId, targetOrgId); + assertUserPartOfOtherOrg(userToMoveToOrg, userName, userId, targetOrgId); + + if (!targetOrgUsername) { + targetOrgUsername = getOrgUsernameFromEmail( + userToMoveToOrg.email, + targetOrganization.metadata.orgAutoAcceptEmail || "" + ); + } + + const userWithSameUsernameInOrg = await prisma.user.findFirst({ + where: { + username: targetOrgUsername, + organizationId: targetOrgId, + }, + }); + + log.debug({ + userWithSameUsernameInOrg, + targetOrgUsername, + targetOrgId, + userId, + }); + + if (userWithSameUsernameInOrg && userWithSameUsernameInOrg.id !== userId) { + throw new HttpError({ + statusCode: 400, + message: `Username ${targetOrgUsername} already exists for orgId: ${targetOrgId} for some other user`, + }); + } + + assertUserPartOfOrgAndRemigrationAllowed(userToMoveToOrg, targetOrgId, targetOrgUsername, userId); + + const orgMetadata = teamMetadata; + + const userToMoveToOrgMetadata = (userToMoveToOrg.metadata || {}) as UserMetadata; + + const nonOrgUserName = + (userToMoveToOrgMetadata.migratedToOrgFrom?.username as string) || userToMoveToOrg.username; + if (!nonOrgUserName) { + throw new HttpError({ + statusCode: 400, + message: `User with id: ${userId} doesn't have a non-org username`, + }); + } + + await dbMoveUserToOrg({ userToMoveToOrg, targetOrgId, targetOrgUsername, nonOrgUserName }); + + let teamsToBeMovedToOrg; + if (shouldMoveTeams) { + teamsToBeMovedToOrg = await moveTeamsWithoutMembersToOrg({ targetOrgId, userToMoveToOrg }); + } + + await updateMembership({ targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted }); + + await addRedirect({ + nonOrgUserName, + teamsToBeMovedToOrg: teamsToBeMovedToOrg || [], + organization: targetOrganization, + targetOrgUsername, + }); + + await setOrgSlugIfNotSet(targetOrganization, orgMetadata, targetOrgId); + + log.debug(`orgId:${targetOrgId} attached to userId:${userId}`); +} + +/** + * Make sure that the migration is idempotent + */ +export async function removeUserFromOrg({ targetOrgId, userId }: { targetOrgId: number; userId: number }) { + const userToRemoveFromOrg = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!userToRemoveFromOrg) { + throw new HttpError({ + statusCode: 400, + message: `User with id: ${userId} not found`, + }); + } + + if (userToRemoveFromOrg.organizationId !== targetOrgId) { + throw new HttpError({ + statusCode: 400, + message: `User with id: ${userId} is not part of orgId: ${targetOrgId}`, + }); + } + + const userToRemoveFromOrgMetadata = (userToRemoveFromOrg.metadata || {}) as { + migratedToOrgFrom?: { + username: string; + reverted: boolean; + revertTime: string; + lastMigrationTime: string; + }; + }; + + if (!userToRemoveFromOrgMetadata.migratedToOrgFrom) { + throw new HttpError({ + statusCode: 400, + message: `User with id: ${userId} wasn't migrated. So, there is nothing to revert`, + }); + } + + const nonOrgUserName = userToRemoveFromOrgMetadata.migratedToOrgFrom.username as string; + if (!nonOrgUserName) { + throw new HttpError({ + statusCode: 500, + message: `User with id: ${userId} doesn't have a non-org username`, + }); + } + + const teamsToBeRemovedFromOrg = await removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }); + await dbRemoveUserFromOrg({ userToRemoveFromOrg, nonOrgUserName }); + + await removeUserAlongWithItsTeamsRedirects({ nonOrgUserName, teamsToBeRemovedFromOrg }); + await removeMembership({ targetOrgId, userToRemoveFromOrg }); + + log.debug(`orgId:${targetOrgId} attached to userId:${userId}`); +} + +/** + * Make sure that the migration is idempotent + */ +export async function moveTeamToOrg({ + targetOrgId, + teamId, + moveMembers, +}: { + targetOrgId: number; + teamId: number; + moveMembers?: boolean; +}) { + const possibleOrg = await getTeamOrThrowError(targetOrgId); + const movedTeam = await dbMoveTeamToOrg({ teamId, targetOrgId }); + + const teamMetadata = teamMetadataSchema.parse(possibleOrg?.metadata); + + if (!teamMetadata?.isOrganization) { + throw new Error(`${targetOrgId} is not an Org`); + } + + const targetOrganization = possibleOrg; + const orgMetadata = teamMetadata; + await addTeamRedirect(movedTeam.slug, targetOrganization.slug || orgMetadata.requestedSlug || null); + await setOrgSlugIfNotSet({ slug: targetOrganization.slug }, orgMetadata, targetOrgId); + if (moveMembers) { + for (const membership of movedTeam.members) { + await moveUserToOrg({ + user: { + id: membership.userId, + }, + targetOrg: { + id: targetOrgId, + membership: { + role: membership.role, + accepted: membership.accepted, + }, + }, + shouldMoveTeams: false, + }); + } + } + log.debug(`Successfully moved team ${teamId} to org ${targetOrgId}`); +} + +/** + * Make sure that the migration is idempotent + */ +export async function removeTeamFromOrg({ targetOrgId, teamId }: { targetOrgId: number; teamId: number }) { + const removedTeam = await dbRemoveTeamFromOrg({ teamId, targetOrgId }); + + await removeTeamRedirect(removedTeam.slug); + + log.debug(`Successfully removed team ${teamId} from org ${targetOrgId}`); +} + +async function dbMoveTeamToOrg({ teamId, targetOrgId }: { teamId: number; targetOrgId: number }) { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + include: { + members: true, + }, + }); + + if (!team) { + throw new HttpError({ + statusCode: 400, + message: `Team with id: ${teamId} not found`, + }); + } + + if (team.parentId === targetOrgId) { + log.warn(`Team ${teamId} is already in org ${targetOrgId}`); + return team; + } + + await prisma.team.update({ + where: { + id: teamId, + }, + data: { + parentId: targetOrgId, + }, + }); + + return team; +} + +async function getUniqueUserThatDoesntBelongToOrg( + userName: string | undefined, + userId: number | undefined, + excludeOrgId: number +) { + log.debug("getUniqueUserThatDoesntBelongToOrg", { userName, userId, excludeOrgId }); + if (userName) { + const matchingUsers = await prisma.user.findMany({ + where: { + username: userName, + }, + }); + const foundUsers = matchingUsers.filter( + (user) => user.organizationId === excludeOrgId || user.organizationId === null + ); + if (foundUsers.length > 1) { + throw new Error(`More than one user found with username: ${userName}`); + } + return foundUsers[0]; + } else { + return await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + } +} + +async function setOrgSlugIfNotSet( + targetOrganization: { + slug: string | null; + }, + orgMetadata: { + requestedSlug?: string | undefined; + }, + targetOrgId: number +) { + if (targetOrganization.slug) { + return; + } + if (!orgMetadata.requestedSlug) { + throw new HttpError({ + statusCode: 400, + message: `Org with id: ${targetOrgId} doesn't have a slug. Tried using requestedSlug but that's also not present. So, all migration done but failed to set the Organization slug. Please set it manually`, + }); + } + await setOrgSlug({ + targetOrgId, + targetSlug: orgMetadata.requestedSlug, + }); +} + +function assertUserPartOfOrgAndRemigrationAllowed( + userToMoveToOrg: { + organizationId: User["organizationId"]; + }, + targetOrgId: number, + targetOrgUsername: string, + userId: number | undefined +) { + if (userToMoveToOrg.organizationId) { + if (userToMoveToOrg.organizationId !== targetOrgId) { + throw new HttpError({ + statusCode: 400, + message: `User ${targetOrgUsername} already exists for different Org with orgId: ${targetOrgId}`, + }); + } else { + log.debug(`Redoing migration for userId: ${userId} to orgId:${targetOrgId}`); + } + } +} + +async function getTeamOrThrowError(targetOrgId: number) { + const team = await prisma.team.findUnique({ + where: { + id: targetOrgId, + }, + }); + + if (!team) { + throw new HttpError({ + statusCode: 400, + message: `Org with id: ${targetOrgId} not found`, + }); + } + return team; +} + +function assertUserPartOfOtherOrg( + userToMoveToOrg: { + organizationId: User["organizationId"]; + } | null, + userName: string | undefined, + userId: number | undefined, + targetOrgId: number +): asserts userToMoveToOrg { + if (!userToMoveToOrg) { + throw new HttpError({ + message: `User ${userName ? userName : `ID:${userId}`} is part of an org already`, + statusCode: 400, + }); + } + + if (userToMoveToOrg.organizationId && userToMoveToOrg.organizationId !== targetOrgId) { + throw new HttpError({ + message: `User is already a part of different organization ID: ${userToMoveToOrg.organizationId}`, + statusCode: 400, + }); + } +} + +function assertUserIdOrUserName(userId: number | undefined, userName: string | undefined) { + if (!userId && !userName) { + throw new HttpError({ statusCode: 400, message: "userId or userName is required" }); + } + if (userId && userName) { + throw new HttpError({ statusCode: 400, message: "Provide either userId or userName" }); + } +} + +async function addRedirect({ + nonOrgUserName, + organization, + targetOrgUsername, + teamsToBeMovedToOrg, +}: { + nonOrgUserName: string | null; + organization: Team; + targetOrgUsername: string; + teamsToBeMovedToOrg: { slug: string | null }[]; +}) { + if (!nonOrgUserName) { + return; + } + const orgSlug = organization.slug || (organization.metadata as { requestedSlug?: string })?.requestedSlug; + if (!orgSlug) { + log.debug("No slug for org. Not adding the redirect", safeStringify({ organization, nonOrgUserName })); + return; + } + // If the user had a username earlier, we need to redirect it to the new org username + const orgUrlPrefix = getOrgFullOrigin(orgSlug); + log.debug({ + orgUrlPrefix, + nonOrgUserName, + targetOrgUsername, + }); + + await prisma.tempOrgRedirect.upsert({ + where: { + from_type_fromOrgId: { + type: RedirectType.User, + from: nonOrgUserName, + fromOrgId: 0, + }, + }, + create: { + type: RedirectType.User, + from: nonOrgUserName, + fromOrgId: 0, + toUrl: `${orgUrlPrefix}/${targetOrgUsername}`, + }, + update: { + toUrl: `${orgUrlPrefix}/${targetOrgUsername}`, + }, + }); + + for (const [, team] of Object.entries(teamsToBeMovedToOrg)) { + if (!team.slug) { + log.debug("No slug for team. Not adding the redirect", safeStringify({ team })); + continue; + } + await prisma.tempOrgRedirect.upsert({ + where: { + from_type_fromOrgId: { + type: RedirectType.Team, + from: team.slug, + fromOrgId: 0, + }, + }, + create: { + type: RedirectType.Team, + from: team.slug, + fromOrgId: 0, + toUrl: `${orgUrlPrefix}/team/${team.slug}`, + }, + update: { + toUrl: `${orgUrlPrefix}/team/${team.slug}`, + }, + }); + } +} + +async function addTeamRedirect(teamSlug: string | null, orgSlug: string | null) { + if (!teamSlug) { + throw new HttpError({ + statusCode: 400, + message: "No slug for team. Not removing the redirect", + }); + } + if (!orgSlug) { + log.warn(`No slug for org. Not adding the redirect`); + return; + } + const orgUrlPrefix = getOrgFullOrigin(orgSlug); + + await prisma.tempOrgRedirect.upsert({ + where: { + from_type_fromOrgId: { + type: RedirectType.Team, + from: teamSlug, + fromOrgId: 0, + }, + }, + create: { + type: RedirectType.Team, + from: teamSlug, + fromOrgId: 0, + toUrl: `${orgUrlPrefix}/${teamSlug}`, + }, + update: { + toUrl: `${orgUrlPrefix}/${teamSlug}`, + }, + }); +} + +async function updateMembership({ + targetOrgId, + userToMoveToOrg, + targetOrgRole, + targetOrgMembershipAccepted, +}: { + targetOrgId: number; + userToMoveToOrg: User; + targetOrgRole: MembershipRole; + targetOrgMembershipAccepted: boolean; +}) { + log.debug("updateMembership", { targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted }); + await prisma.membership.upsert({ + where: { + userId_teamId: { + teamId: targetOrgId, + userId: userToMoveToOrg.id, + }, + }, + create: { + teamId: targetOrgId, + userId: userToMoveToOrg.id, + role: targetOrgRole, + accepted: targetOrgMembershipAccepted, + }, + update: { + role: targetOrgRole, + accepted: targetOrgMembershipAccepted, + }, + }); +} + +async function dbMoveUserToOrg({ + userToMoveToOrg, + targetOrgId, + targetOrgUsername, + nonOrgUserName, +}: { + userToMoveToOrg: User; + targetOrgId: number; + targetOrgUsername: string; + nonOrgUserName: string | null; +}) { + await prisma.user.update({ + where: { + id: userToMoveToOrg.id, + }, + data: { + organizationId: targetOrgId, + username: targetOrgUsername, + metadata: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...(userToMoveToOrg.metadata || {}), + migratedToOrgFrom: { + username: nonOrgUserName, + lastMigrationTime: new Date().toISOString(), + }, + }, + }, + }); +} + +async function moveTeamsWithoutMembersToOrg({ + targetOrgId, + userToMoveToOrg, +}: { + targetOrgId: number; + userToMoveToOrg: User; +}) { + const memberships = await prisma.membership.findMany({ + where: { + userId: userToMoveToOrg.id, + }, + }); + + const membershipTeamIds = memberships.map((m) => m.teamId); + const teams = await prisma.team.findMany({ + where: { + id: { + in: membershipTeamIds, + }, + }, + select: { + id: true, + slug: true, + metadata: true, + }, + }); + + const teamsToBeMovedToOrg = teams + .map((team) => { + return { + ...team, + metadata: teamMetadataSchema.parse(team.metadata), + }; + }) + // Remove Orgs from the list + .filter((team) => !team.metadata?.isOrganization); + + const teamIdsToBeMovedToOrg = teamsToBeMovedToOrg.map((t) => t.id); + + if (memberships.length) { + // Add the user's teams to the org + await prisma.team.updateMany({ + where: { + id: { + in: teamIdsToBeMovedToOrg, + }, + }, + data: { + parentId: targetOrgId, + }, + }); + } + return teamsToBeMovedToOrg; +} + +/** + * Make sure you pass it an organization ID only and not a team ID. + */ +async function setOrgSlug({ targetOrgId, targetSlug }: { targetOrgId: number; targetSlug: string }) { + await prisma.team.update({ + where: { + id: targetOrgId, + }, + data: { + slug: targetSlug, + }, + }); +} + +async function removeTeamRedirect(teamSlug: string | null) { + if (!teamSlug) { + throw new HttpError({ + statusCode: 400, + message: "No slug for team. Not removing the redirect", + }); + return; + } + + await prisma.tempOrgRedirect.deleteMany({ + where: { + type: RedirectType.Team, + from: teamSlug, + fromOrgId: 0, + }, + }); +} + +async function removeUserAlongWithItsTeamsRedirects({ + nonOrgUserName, + teamsToBeRemovedFromOrg, +}: { + nonOrgUserName: string | null; + teamsToBeRemovedFromOrg: { slug: string | null }[]; +}) { + if (!nonOrgUserName) { + return; + } + + await prisma.tempOrgRedirect.deleteMany({ + // This where clause is unique, so we will get only one result but using deleteMany because it doesn't throw an error if there are no rows to delete + where: { + type: RedirectType.User, + from: nonOrgUserName, + fromOrgId: 0, + }, + }); + + for (const [, team] of Object.entries(teamsToBeRemovedFromOrg)) { + if (!team.slug) { + log.debug("No slug for team. Not removing the redirect", safeStringify({ team })); + continue; + } + await prisma.tempOrgRedirect.deleteMany({ + where: { + type: RedirectType.Team, + from: team.slug, + fromOrgId: 0, + }, + }); + } +} + +async function dbRemoveTeamFromOrg({ teamId, targetOrgId }: { teamId: number; targetOrgId: number }) { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + }); + + if (!team) { + throw new HttpError({ + statusCode: 400, + message: `Team with id: ${teamId} not found`, + }); + } + + if (team.parentId !== targetOrgId) { + log.warn(`Team ${teamId} is not part of org ${targetOrgId}. Not updating`); + return { + slug: team.slug, + }; + } + + try { + return await prisma.team.update({ + where: { + id: teamId, + }, + data: { + parentId: null, + }, + select: { + slug: true, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new HttpError({ + message: `Looks like the team's name is already taken by some other team outside the org or an org itself. Please change this team's name or the other team/org's name. If you rename the team that you are trying to remove from the org, you will have to manually remove the redirect from the database for that team as the slug would have changed.`, + statusCode: 400, + }); + } + } + throw e; + } +} + +async function removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }: { userToRemoveFromOrg: User }) { + const memberships = await prisma.membership.findMany({ + where: { + userId: userToRemoveFromOrg.id, + }, + }); + + const membershipTeamIds = memberships.map((m) => m.teamId); + const teams = await prisma.team.findMany({ + where: { + id: { + in: membershipTeamIds, + }, + }, + select: { + id: true, + slug: true, + metadata: true, + }, + }); + + const teamsToBeRemovedFromOrg = teams + .map((team) => { + return { + ...team, + metadata: teamMetadataSchema.parse(team.metadata), + }; + }) + // Remove Orgs from the list + .filter((team) => !team.metadata?.isOrganization); + + const teamIdsToBeRemovedFromOrg = teamsToBeRemovedFromOrg.map((t) => t.id); + + if (memberships.length) { + // Remove the user's teams from the org + await prisma.team.updateMany({ + where: { + id: { + in: teamIdsToBeRemovedFromOrg, + }, + }, + data: { + parentId: null, + }, + }); + } + return teamsToBeRemovedFromOrg; +} + +async function dbRemoveUserFromOrg({ + userToRemoveFromOrg, + nonOrgUserName, +}: { + userToRemoveFromOrg: User; + nonOrgUserName: string; +}) { + await prisma.user.update({ + where: { + id: userToRemoveFromOrg.id, + }, + data: { + organizationId: null, + username: nonOrgUserName, + metadata: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...(userToRemoveFromOrg.metadata || {}), + migratedToOrgFrom: { + username: null, + reverted: true, + revertTime: new Date().toISOString(), + }, + }, + }, + }); +} + +async function removeMembership({ + targetOrgId, + userToRemoveFromOrg, +}: { + targetOrgId: number; + userToRemoveFromOrg: User; +}) { + await prisma.membership.deleteMany({ + where: { + teamId: targetOrgId, + userId: userToRemoveFromOrg.id, + }, + }); +} diff --git a/apps/web/pages/api/orgMigration/moveTeamToOrg.ts b/apps/web/pages/api/orgMigration/moveTeamToOrg.ts new file mode 100644 index 00000000000000..bed2778fbadd02 --- /dev/null +++ b/apps/web/pages/api/orgMigration/moveTeamToOrg.ts @@ -0,0 +1,73 @@ +import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveTeamToOrg"; +import type { NextApiRequest, NextApiResponse } from "next/types"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server"; +import { UserPermissionRole } from "@calcom/prisma/enums"; + +import { moveTeamToOrg } from "../../../lib/orgMigration"; + +const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const rawBody = req.body; + + log.debug( + "Moving team to org:", + safeStringify({ + body: rawBody, + }) + ); + + const translate = await getTranslation("en", "common"); + const moveTeamToOrgSchema = getFormSchema(translate); + + const parsedBody = moveTeamToOrgSchema.safeParse(rawBody); + + const session = await getServerSession({ req, res }); + + if (!session) { + return res.status(403).json({ message: "No session found" }); + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + + if (!parsedBody.success) { + log.error("moveTeamToOrg failed:", safeStringify(parsedBody.error)); + return res.status(400).json({ message: JSON.stringify(parsedBody.error) }); + } + + const { teamId, targetOrgId, moveMembers } = parsedBody.data; + const isAllowed = isAdmin; + if (!isAllowed) { + return res.status(403).json({ message: "Not Authorized" }); + } + + try { + await moveTeamToOrg({ + targetOrgId, + teamId, + moveMembers, + }); + } catch (error) { + if (error instanceof HttpError) { + if (error.statusCode > 300) { + log.error("moveTeamToOrg failed:", safeStringify(error.message)); + } + return res.status(error.statusCode).json({ message: error.message }); + } + log.error("moveTeamToOrg failed:", safeStringify(error)); + const errorMessage = error instanceof Error ? error.message : "Something went wrong"; + + return res.status(500).json({ message: errorMessage }); + } + + return res.status(200).json({ + message: `Added team ${teamId} to Org: ${targetOrgId} ${ + moveMembers ? " along with the members" : " without the members" + }`, + }); +} diff --git a/apps/web/pages/api/orgMigration/moveUserToOrg.ts b/apps/web/pages/api/orgMigration/moveUserToOrg.ts new file mode 100644 index 00000000000000..82b299c5b842c1 --- /dev/null +++ b/apps/web/pages/api/orgMigration/moveUserToOrg.ts @@ -0,0 +1,75 @@ +import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveUserToOrg"; +import type { NextApiRequest, NextApiResponse } from "next/types"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server"; +import { UserPermissionRole } from "@calcom/prisma/enums"; + +import { moveUserToOrg } from "../../../lib/orgMigration"; + +const log = logger.getSubLogger({ prefix: ["moveUserToOrg"] }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const rawBody = req.body; + const translate = await getTranslation("en", "common"); + const migrateBodySchema = getFormSchema(translate); + log.debug( + "Starting migration:", + safeStringify({ + body: rawBody, + }) + ); + const parsedBody = migrateBodySchema.safeParse(rawBody); + + const session = await getServerSession({ req }); + + if (!session) { + res.status(403).json({ message: "No session found" }); + return; + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + + if (parsedBody.success) { + const { userId, userName, shouldMoveTeams, targetOrgId, targetOrgUsername, targetOrgRole } = + parsedBody.data; + const isAllowed = isAdmin; + if (isAllowed) { + try { + await moveUserToOrg({ + targetOrg: { + id: targetOrgId, + username: targetOrgUsername, + membership: { + role: targetOrgRole, + }, + }, + user: { + id: userId, + userName, + }, + shouldMoveTeams, + }); + } catch (error) { + if (error instanceof HttpError) { + if (error.statusCode > 300) { + log.error("Migration failed:", safeStringify(error)); + } + return res.status(error.statusCode).json({ message: error.message }); + } + log.error("Migration failed:", safeStringify(error)); + const errorMessage = error instanceof Error ? error.message : "Something went wrong"; + + return res.status(400).json({ message: errorMessage }); + } + } else { + return res.status(403).json({ message: "Not Authorized" }); + } + return res.status(200).json({ message: "Migrated" }); + } + log.error("Migration failed:", safeStringify(parsedBody.error)); + return res.status(400).json({ message: JSON.stringify(parsedBody.error) }); +} diff --git a/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts b/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts new file mode 100644 index 00000000000000..fc4a88e4bb4c18 --- /dev/null +++ b/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts @@ -0,0 +1,63 @@ +import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeTeamFromOrg"; +import type { NextApiRequest, NextApiResponse } from "next/types"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server"; +import { UserPermissionRole } from "@calcom/prisma/enums"; + +import { removeTeamFromOrg } from "../../../lib/orgMigration"; + +const log = logger.getSubLogger({ prefix: ["removeTeamFromOrg"] }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const rawBody = req.body; + const translate = await getTranslation("en", "common"); + const removeTeamFromOrgSchema = getFormSchema(translate); + log.debug( + "Removing team from org:", + safeStringify({ + body: rawBody, + }) + ); + const parsedBody = removeTeamFromOrgSchema.safeParse(rawBody); + + const session = await getServerSession({ req }); + + if (!session) { + return res.status(403).json({ message: "No session found" }); + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + + if (!parsedBody.success) { + log.error("RemoveTeamFromOrg failed:", safeStringify(parsedBody.error)); + return res.status(400).json({ message: JSON.stringify(parsedBody.error) }); + } + const { teamId, targetOrgId } = parsedBody.data; + const isAllowed = isAdmin; + if (!isAllowed) { + return res.status(403).json({ message: "Not Authorized" }); + } + + try { + await removeTeamFromOrg({ + targetOrgId, + teamId, + }); + } catch (error) { + if (error instanceof HttpError) { + if (error.statusCode > 300) { + log.error("RemoveTeamFromOrg failed:", safeStringify(error)); + } + return res.status(error.statusCode).json({ message: error.message }); + } + log.error("RemoveTeamFromOrg failed:", safeStringify(error)); + const errorMessage = error instanceof Error ? error.message : "Something went wrong"; + return res.status(500).json({ message: errorMessage }); + } + + return res.status(200).json({ message: `Removed team ${teamId} from ${targetOrgId}` }); +} diff --git a/apps/web/pages/api/orgMigration/removeUserFromOrg.ts b/apps/web/pages/api/orgMigration/removeUserFromOrg.ts new file mode 100644 index 00000000000000..ce48fc07e9d9c8 --- /dev/null +++ b/apps/web/pages/api/orgMigration/removeUserFromOrg.ts @@ -0,0 +1,59 @@ +import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeUserFromOrg"; +import type { NextApiRequest, NextApiResponse } from "next/types"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server"; +import { UserPermissionRole } from "@calcom/prisma/enums"; + +import { removeUserFromOrg } from "../../../lib/orgMigration"; + +const log = logger.getSubLogger({ prefix: ["removeUserFromOrg"] }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const body = req.body; + + log.debug( + "Starting reverse migration:", + safeStringify({ + body, + }) + ); + + const translate = await getTranslation("en", "common"); + const migrateRevertBodySchema = getFormSchema(translate); + const parsedBody = migrateRevertBodySchema.safeParse(body); + const session = await getServerSession({ req }); + + if (!session) { + return res.status(403).json({ message: "No session found" }); + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + if (!isAdmin) { + return res.status(403).json({ message: "Only admin can take this action" }); + } + + if (parsedBody.success) { + const { userId, targetOrgId } = parsedBody.data; + try { + await removeUserFromOrg({ targetOrgId, userId }); + } catch (error) { + if (error instanceof HttpError) { + if (error.statusCode > 300) { + log.error("Reverse migration failed:", safeStringify(error)); + } + return res.status(error.statusCode).json({ message: error.message }); + } + log.error("Reverse migration failed:", safeStringify(error)); + const errorMessage = error instanceof Error ? error.message : "Something went wrong"; + + return res.status(500).json({ message: errorMessage }); + } + return res.status(200).json({ message: "Reverted" }); + } + log.error("Reverse Migration failed:", safeStringify(parsedBody.error)); + return res.status(400).json({ message: JSON.stringify(parsedBody.error) }); +} diff --git a/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx b/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx new file mode 100644 index 00000000000000..cf51a4a792c884 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx @@ -0,0 +1,33 @@ +import { getLayout as getSettingsLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { HorizontalTabs } from "@calcom/ui"; + +export default function OrgMigrationLayout({ children }: { children: React.ReactElement }) { + return getSettingsLayout( +
    + + {children} +
    + ); +} +export const getLayout = (page: React.ReactElement) => { + return {page}; +}; diff --git a/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx new file mode 100644 index 00000000000000..4ed978df88b9e1 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx @@ -0,0 +1,175 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { GetServerSidePropsContext } from "next"; +import { getSession } from "next-auth/react"; +import type { TFunction } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils"; +import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +import { getLayout } from "./_OrgMigrationLayout"; + +export const getFormSchema = (t: TFunction) => { + return z.object({ + teamId: z.number().or(getStringAsNumberRequiredSchema(t)), + targetOrgId: z.number().or(getStringAsNumberRequiredSchema(t)), + moveMembers: z.boolean(), + }); +}; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +const enum State { + IDLE, + LOADING, + SUCCESS, + ERROR, +} + +export default function MoveTeamToOrg() { + const [state, setState] = useState(State.IDLE); + const moveUsersOptions = [ + { + label: "No", + value: "false", + }, + { + label: "Yes", + value: "true", + }, + ]; + const { t } = useLocale(); + const formSchema = getFormSchema(t); + const formMethods = useForm({ + mode: "onSubmit", + resolver: zodResolver(formSchema), + }); + + const { register, watch } = formMethods; + const moveMembers = watch("moveMembers"); + return ( + + { + setState(State.LOADING); + const res = await fetch(`/api/orgMigration/moveTeamToOrg`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + let response = null; + try { + response = await res.json(); + } catch (e) { + if (e instanceof Error) { + showToast(e.message, "error", 10000); + } else { + showToast(t("something_went_wrong"), "error", 10000); + } + setState(State.ERROR); + return; + } + if (res.status === 200) { + setState(State.SUCCESS); + showToast(response.message, "success", 10000); + } else { + setState(State.ERROR); + showToast(response.message, "error", 10000); + } + }}> +
    + + +
    + ( + { + onChange(option?.value === "true"); + }} + value={moveUsersOptions.find((opt) => opt.value === value)} + options={moveUsersOptions} + /> + )} + /> + + {moveMembers === true ? ( +
    Members of the team will also be moved to the organization
    + ) : moveMembers === false ? ( +
    Members of the team will not be moved to the organization
    + ) : null} +
    +
    + + +
    + ); +} + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const session = await getSession(ctx); + if (!session || !session.user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + return { + props: { + error: null, + migrated: null, + userId: session.user.id, + ...(await serverSideTranslations(ctx.locale || "en", ["common"])), + username: session.user.username, + }, + }; +} + +MoveTeamToOrg.PageWrapper = PageWrapper; +MoveTeamToOrg.getLayout = getLayout; diff --git a/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx new file mode 100644 index 00000000000000..784152caa73238 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx @@ -0,0 +1,213 @@ +/** + * It could be an admin feature to move a user to an organization but because it's a temporary thing before mono-user orgs are implemented, it's not right to spend time on it. + * Plus, we need to do it only for cal.com and not provide as a feature to our self hosters. + */ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { GetServerSidePropsContext } from "next"; +import { getSession } from "next-auth/react"; +import type { TFunction } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { MembershipRole } from "@calcom/prisma/client"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils"; +import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +import { getLayout } from "./_OrgMigrationLayout"; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +export const getFormSchema = (t: TFunction) => + z.object({ + userId: z.union([z.string().pipe(z.coerce.number()), z.number()]).optional(), + userName: z.string().optional(), + targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]), + targetOrgUsername: z.string().min(1, t("error_required_field")), + shouldMoveTeams: z.boolean(), + targetOrgRole: z.union([ + z.literal(MembershipRole.ADMIN), + z.literal(MembershipRole.MEMBER), + z.literal(MembershipRole.OWNER), + ]), + }); + +const enum State { + IDLE, + LOADING, + SUCCESS, + ERROR, +} +export default function MoveUserToOrg() { + const [state, setState] = useState(State.IDLE); + + const roles = Object.values(MembershipRole).map((role) => ({ + label: role, + value: role, + })); + + const moveTeamsOptions = [ + { + label: "Yes", + value: "true", + }, + { + label: "No", + value: "false", + }, + ]; + const { t } = useLocale(); + const formSchema = getFormSchema(t); + const form = useForm({ + mode: "onSubmit", + resolver: zodResolver(formSchema), + }); + + const shouldMoveTeams = form.watch("shouldMoveTeams"); + const register = form.register; + return ( + + {/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */} +
    { + setState(State.LOADING); + const res = await fetch(`/api/orgMigration/moveUserToOrg`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + let response = null; + try { + response = await res.json(); + } catch (e) { + if (e instanceof Error) { + showToast(e.message, "error", 10000); + } else { + showToast(t("something_went_wrong"), "error", 10000); + } + setState(State.ERROR); + return; + } + if (res.status === 200) { + setState(State.SUCCESS); + showToast(response.message, "success", 10000); + } else { + setState(State.ERROR); + showToast(response.message, "error", 10000); + } + }}> +
    + + ( + { + if (!option) return; + onChange(option.value); + }} + value={roles.find((role) => role.value === value)} + required + placeholder="Enter userId" + /> + )} + /> + + + ( + { + if (!option) return; + onChange(option.value === "true"); + }} + value={moveTeamsOptions.find((opt) => opt.value === value)} + required + options={moveTeamsOptions} + /> + )} + /> +
    + + +
    +
    + ); +} + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const session = await getSession(ctx); + if (!session || !session.user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + return { + props: { + ...(await serverSideTranslations(ctx.locale || "en", ["common"])), + }, + }; +} + +MoveUserToOrg.PageWrapper = PageWrapper; +MoveUserToOrg.getLayout = getLayout; diff --git a/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx new file mode 100644 index 00000000000000..17a90f115afac9 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx @@ -0,0 +1,142 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { GetServerSidePropsContext } from "next"; +import { getSession } from "next-auth/react"; +import type { TFunction } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils"; +import { Button, Form, Meta, TextField, showToast } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +import { getLayout } from "./_OrgMigrationLayout"; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +const enum State { + IDLE, + LOADING, + SUCCESS, + ERROR, +} + +export const getFormSchema = (t: TFunction) => + z.object({ + targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]), + teamId: z.union([getStringAsNumberRequiredSchema(t), z.number()]), + }); + +export default function RemoveTeamFromOrg() { + const [state, setState] = useState(State.IDLE); + const { t } = useLocale(); + const formSchema = getFormSchema(t); + const form = useForm({ + mode: "onSubmit", + resolver: zodResolver(formSchema), + }); + + const register = form.register; + + return ( + + {/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */} +
    { + setState(State.LOADING); + const res = await fetch(`/api/orgMigration/removeTeamFromOrg`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + let response = null; + try { + response = await res.json(); + } catch (e) { + if (e instanceof Error) { + showToast(e.message, "error", 10000); + } else { + showToast(t("something_went_wrong"), "error", 10000); + } + setState(State.ERROR); + return; + } + if (res.status === 200) { + setState(State.SUCCESS); + showToast(response.message, "success", 10000); + } else { + setState(State.ERROR); + showToast(response.message, "error", 10000); + } + }}> +
    + + +
    + +
    +
    + ); +} + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const session = await getSession(ctx); + if (!session || !session.user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + return { + props: { + ...(await serverSideTranslations(ctx.locale || "en", ["common"])), + }, + }; +} + +RemoveTeamFromOrg.PageWrapper = PageWrapper; +RemoveTeamFromOrg.getLayout = getLayout; diff --git a/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx new file mode 100644 index 00000000000000..ef7710ed379b01 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx @@ -0,0 +1,137 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import type { GetServerSidePropsContext } from "next"; +import { getSession } from "next-auth/react"; +import type { TFunction } from "next-i18next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { UserPermissionRole } from "@calcom/prisma/enums"; +import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils"; +import { Button, TextField, Meta, showToast, Form } from "@calcom/ui"; + +import PageWrapper from "@components/PageWrapper"; + +import { getLayout } from "./_OrgMigrationLayout"; + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +const enum State { + IDLE, + LOADING, + SUCCESS, + ERROR, +} + +export const getFormSchema = (t: TFunction) => + z.object({ + userId: z.union([getStringAsNumberRequiredSchema(t), z.number()]), + targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]), + }); + +export default function RemoveUserFromOrg() { + const [state, setState] = useState(State.IDLE); + const { t } = useLocale(); + const formSchema = getFormSchema(t); + const form = useForm({ + mode: "onSubmit", + resolver: zodResolver(formSchema), + }); + const register = form.register; + return ( + + {/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */} +
    { + setState(State.LOADING); + const res = await fetch(`/api/orgMigration/removeUserFromOrg`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + let response = null; + try { + response = await res.json(); + } catch (e) { + if (e instanceof Error) { + showToast(e.message, "error", 10000); + } else { + showToast(t("something_went_wrong"), "error", 10000); + } + setState(State.ERROR); + return; + } + if (res.status === 200) { + setState(State.SUCCESS); + showToast(response.message, "success", 10000); + } else { + setState(State.ERROR); + showToast(response.message, "error", 10000); + } + }}> +
    + + +
    + +
    +
    + ); +} + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const session = await getSession(ctx); + if (!session || !session.user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const isAdmin = session.user.role === UserPermissionRole.ADMIN; + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + return { + props: { + ...(await serverSideTranslations(ctx.locale || "en", ["common"])), + }, + }; +} + +RemoveUserFromOrg.PageWrapper = PageWrapper; +RemoveUserFromOrg.getLayout = getLayout; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 99db8fd19cc16c..484ef740ed7a81 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; import type { UnitTypeLongPlural } from "dayjs"; +import type { TFunction } from "next-i18next"; import z, { ZodNullable, ZodObject, ZodOptional } from "zod"; /* eslint-disable no-underscore-dangle */ @@ -640,3 +641,5 @@ export const ZVerifyCodeInputSchema = z.object({ export type ZVerifyCodeInputSchema = z.infer; export const coerceToDate = z.coerce.date(); +export const getStringAsNumberRequiredSchema = (t: TFunction) => + z.string().min(1, t("error_required_field")).pipe(z.coerce.number()); From 0f707a55b0241d7bc70a2af4197753d08aa93bd1 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Thu, 4 Jan 2024 01:38:27 -0300 Subject: [PATCH 33/42] chore: Upgrade upload-artifact action to v4 (#13025) --- .github/workflows/e2e-app-store.yml | 4 ++-- .github/workflows/e2e-embed-react.yml | 2 +- .github/workflows/e2e-embed.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/nextjs-bundle-analysis.yml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index dcba9d40c97564..8de5d187fced23 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -33,7 +33,7 @@ jobs: - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - uses: ./.github/actions/cache-db - env: + env: DATABASE_URL: ${{ secrets.CI_DATABASE_URL }} E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }} E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }} @@ -75,7 +75,7 @@ jobs: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }} path: test-results diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index 040dec0265a4b2..3c804de3245cdd 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -61,7 +61,7 @@ jobs: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }} path: test-results diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 12be6a85000bbf..9475434818d50c 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -65,7 +65,7 @@ jobs: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }} path: test-results diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ce0cab4cfbcb8a..2fff74a579c1e6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -68,7 +68,7 @@ jobs: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Upload Test Results if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.shard }}_${{ strategy.job-total }} path: test-results diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 47a5c19102a045..9bb6f3bdf740cd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: - name: Upload ESLint report if: ${{ always() }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: lint-results path: lint-results diff --git a/.github/workflows/nextjs-bundle-analysis.yml b/.github/workflows/nextjs-bundle-analysis.yml index 972e8af2253edb..811d232d54283a 100644 --- a/.github/workflows/nextjs-bundle-analysis.yml +++ b/.github/workflows/nextjs-bundle-analysis.yml @@ -27,7 +27,7 @@ jobs: npx -p nextjs-bundle-analysis@0.5.0 report - name: Upload bundle - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: bundle path: apps/web/.next/analyze/__bundle_analysis.json From 2220778e6b134ccbee3db7c339d71c429fd1d0da Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Thu, 4 Jan 2024 01:38:48 -0300 Subject: [PATCH 34/42] chore: Upgrade buildjet cache action to v3 (#13026) --- .github/actions/yarn-playwright-install/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/yarn-playwright-install/action.yml b/.github/actions/yarn-playwright-install/action.yml index 1e118e3099b8d6..601513ee765735 100644 --- a/.github/actions/yarn-playwright-install/action.yml +++ b/.github/actions/yarn-playwright-install/action.yml @@ -5,7 +5,7 @@ runs: steps: - name: Cache playwright binaries id: playwright-cache - uses: buildjet/cache@v2 + uses: buildjet/cache@v3 with: path: | ~/Library/Caches/ms-playwright From 4c4fc9e38bfab1c626bf46cdb1901a30fa4c651c Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Thu, 4 Jan 2024 01:39:17 -0300 Subject: [PATCH 35/42] chore: Fix NODE_OPTIONS error (#13024) --- .github/workflows/check-types.yml | 2 +- .github/workflows/e2e-app-store.yml | 4 ++-- .github/workflows/e2e-embed-react.yml | 4 ++-- .github/workflows/e2e-embed.yml | 4 ++-- .github/workflows/e2e.yml | 5 ++--- .github/workflows/unit-tests.yml | 1 - 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml index 644ca2e9d60170..85f730a3aed883 100644 --- a/.github/workflows/check-types.yml +++ b/.github/workflows/check-types.yml @@ -2,7 +2,7 @@ name: Check types on: workflow_call: env: - NODE_OPTIONS: "--max-old-space-size=8192" + NODE_OPTIONS: --max-old-space-size=4096 jobs: check-types: runs-on: buildjet-4vcpu-ubuntu-2204 diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index 8de5d187fced23..28d26f4e40f0cb 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -1,7 +1,8 @@ name: E2E App-Store Apps Tests on: workflow_call: - +env: + NODE_OPTIONS: --max-old-space-size=4096 jobs: e2e-app-store: timeout-minutes: 20 @@ -29,7 +30,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - uses: ./.github/actions/cache-db diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index 3c804de3245cdd..4b3dc6730643e0 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -1,7 +1,8 @@ name: E2E Embed React tests and booking flow (for non-embed as well) on: workflow_call: - +env: + NODE_OPTIONS: --max-old-space-size=4096 jobs: e2e-embed: timeout-minutes: 20 @@ -24,7 +25,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - uses: ./.github/actions/cache-db diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 9475434818d50c..e41b1aa545cc4a 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -1,7 +1,8 @@ name: E2E Embed Core tests and booking flow (for non-embed as well) on: workflow_call: - +env: + NODE_OPTIONS: --max-old-space-size=4096 jobs: e2e-embed: timeout-minutes: 20 @@ -29,7 +30,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - uses: ./.github/actions/cache-db diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2fff74a579c1e6..4b3e0d94dc4930 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,8 +1,8 @@ name: E2E tests - on: workflow_call: - +env: + NODE_OPTIONS: --max-old-space-size=4096 jobs: e2e: timeout-minutes: 20 @@ -28,7 +28,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=4096"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install - uses: ./.github/actions/yarn-playwright-install - uses: ./.github/actions/cache-db diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d8ca18d2822acd..14027bf444b868 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,7 +11,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/actions/dangerous-git-checkout - - run: echo 'NODE_OPTIONS="--max_old_space_size=6144"' >> $GITHUB_ENV - uses: ./.github/actions/yarn-install # Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners - run: yarn test From c19799e2755438da9cd6ebce560765585407d4cc Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Thu, 4 Jan 2024 01:39:45 -0300 Subject: [PATCH 36/42] chore: Remove invalid DATABASE_URL 'with' value (#13027) --- .github/actions/cache-db/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/cache-db/action.yml b/.github/actions/cache-db/action.yml index 9d2c1ce28dc76b..ffc8f15097f1a2 100644 --- a/.github/actions/cache-db/action.yml +++ b/.github/actions/cache-db/action.yml @@ -24,7 +24,6 @@ runs: with: path: ${{ inputs.path }} key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }} - DATABASE_URL: ${{ inputs.DATABASE_URL }} - run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed if: steps.cache-db.outputs.cache-hit != 'true' shell: bash From 3791af86442763502a2827e8d7039836cf9aad59 Mon Sep 17 00:00:00 2001 From: Riddhesh Mahajan <40472653+riddhesh-mahajan@users.noreply.github.com> Date: Thu, 4 Jan 2024 21:58:23 +0530 Subject: [PATCH 37/42] feat: Make private URLs easier to copy-paste from web app (#13018) * Make private URLs easier to copy-paste from web app * Apply suggestions from code review * Update apps/web/pages/event-types/index.tsx * lint --------- Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen --- apps/web/pages/event-types/index.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index b068c7d6874032..ce15cea75f74ce 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -19,6 +19,7 @@ import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter"; import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; import { ShellMain } from "@calcom/features/shell/Shell"; import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; +import { CAL_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; @@ -66,6 +67,7 @@ import { Trash, Upload, Users, + VenetianMask, } from "@calcom/ui/components/icon"; import useMeQuery from "@lib/hooks/useMeQuery"; @@ -388,6 +390,8 @@ export const EventTypeList = ({ {types.map((type, index) => { const embedLink = `${group.profile.slug}/${type.slug}`; const calLink = `${bookerUrl}/${embedLink}`; + const isPrivateURLEnabled = type.hashedLink?.link; + const placeholderHashedLink = `${CAL_URL}/d/${type.hashedLink?.link}/${type.slug}`; const isManagedEventType = type.schedulingType === SchedulingType.MANAGED; const isChildrenManagedEventType = type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED; @@ -465,6 +469,20 @@ export const EventTypeList = ({ }} /> + + {isPrivateURLEnabled && ( + +
    ))} - {isBookerLayoutsEnabled && ( -
    diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 50e61214a9dc0e..bb03d0f5d42a95 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -12,7 +12,6 @@ export type AppFlags = { "managed-event-types": boolean; organizations: boolean; "email-verification": boolean; - "booker-layouts": boolean; "google-workspace-directory": boolean; "disable-signup": boolean; }; diff --git a/packages/features/settings/BookerLayoutSelector.tsx b/packages/features/settings/BookerLayoutSelector.tsx index 3ac38433bba28e..e097298db00189 100644 --- a/packages/features/settings/BookerLayoutSelector.tsx +++ b/packages/features/settings/BookerLayoutSelector.tsx @@ -4,7 +4,6 @@ import Link from "next/link"; import { useCallback, useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; -import { useFlagMap } from "@calcom/features/flags/context/provider"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils"; @@ -53,9 +52,6 @@ export const BookerLayoutSelector = ({ // Only fallback if event current does not have any settings, and the fallbackToUserSettings boolean is set. const shouldShowUserSettings = (fallbackToUserSettings && !getValues(name || defaultFieldName)) || false; - const flags = useFlagMap(); - if (flags["booker-layouts"] !== true) return null; - return (
    diff --git a/packages/prisma/migrations/20240105110500_removed_newbooker_feature_flag/migration.sql b/packages/prisma/migrations/20240105110500_removed_newbooker_feature_flag/migration.sql new file mode 100644 index 00000000000000..bdf05ec0ab82ec --- /dev/null +++ b/packages/prisma/migrations/20240105110500_removed_newbooker_feature_flag/migration.sql @@ -0,0 +1,4 @@ +-- Removes the feature flag for the new booker layouts which is no longer needed +DELETE FROM "Feature" +WHERE + slug = 'booker-layouts'; From ecb693c70e9b3a89f6442503e1fc005ee92dc3b6 Mon Sep 17 00:00:00 2001 From: DmytroHryshyn <125881252+DmytroHryshyn@users.noreply.github.com> Date: Thu, 4 Jan 2024 20:26:11 +0200 Subject: [PATCH 40/42] chore: [app-router-migration 8.6] reorganize future pages file structure (#12988) * make no-meeting-found page use ssr * remove-route-groups * add LayoutHOC * fix * fix * ensure proper types for withLayout function --------- Co-authored-by: Greg Pabian <35925521+grzpab@users.noreply.github.com> --- apps/web/app/_types.ts | 11 ++++++ .../apps/[slug]/layout.tsx | 15 -------- .../apps/installed/[category]/layout.tsx | 15 -------- .../(admin-layout)/layout.tsx | 20 ----------- .../settings/admin/oAuth/page.tsx | 10 ------ .../(admin-layout)/settings/admin/page.tsx | 10 ------ .../(shared-page-wrapper)/(layout)/layout.tsx | 22 ------------ .../(no-layout)/layout.tsx | 20 ----------- .../video/no-meeting-found/page.tsx | 10 ------ .../(settings-layout)/layout.tsx | 21 ------------ apps/web/app/future/apps/[slug]/layout.tsx | 3 ++ .../apps/[slug]/page.tsx | 0 .../apps/[slug]/setup/page.tsx | 0 .../apps/categories/[category]/page.tsx | 14 ++------ .../apps/categories/page.tsx | 15 ++------ .../apps/installed/[category]/layout.tsx | 3 ++ .../apps/installed/[category]/page.tsx | 0 .../bookings/[status]/layout.tsx | 13 ++----- .../bookings/[status]/page.tsx | 0 apps/web/app/future/event-types/layout.tsx | 5 +++ .../(layout) => }/event-types/page.tsx | 0 .../settings/admin/apps/[category]/page.tsx | 0 .../app/future/settings/admin/apps/layout.tsx | 5 +++ .../settings/admin/apps/page.tsx | 0 .../future/settings/admin/flags/layout.tsx | 5 +++ .../settings/admin/flags/page.tsx | 0 .../settings/admin/impersonation/layout.tsx | 5 +++ .../settings/admin/impersonation/page.tsx | 0 .../settings/admin/oAuth/oAuthView/layout.tsx | 3 ++ .../settings/admin/oAuth/oAuthView/page.tsx | 0 .../app/future/settings/admin/oAuth/page.tsx | 13 +++++++ .../settings/admin/organizations/layout.tsx | 5 +++ .../settings/admin/organizations/page.tsx | 0 apps/web/app/future/settings/admin/page.tsx | 13 +++++++ .../settings/admin/users/[id]/edit/page.tsx | 0 .../settings/admin/users/add/page.tsx | 0 .../future/settings/admin/users/layout.tsx | 5 +++ .../settings/admin/users/page.tsx | 0 .../teams/page.tsx | 34 ++----------------- .../video/[uid]/page.tsx | 34 ++----------------- .../video/meeting-ended/[uid]/page.tsx | 29 ++-------------- .../video/meeting-not-started/[uid]/page.tsx | 24 ++----------- .../future/video/no-meeting-found/page.tsx | 20 +++++++++++ apps/web/app/layoutHOC.tsx | 28 +++++++++++++++ apps/web/components/PageWrapperAppDir.tsx | 4 +-- 45 files changed, 145 insertions(+), 289 deletions(-) delete mode 100644 apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx delete mode 100644 apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/layout.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(admin-layout)/layout.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/oAuth/page.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/page.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(no-layout)/layout.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(no-layout)/video/no-meeting-found/page.tsx delete mode 100644 apps/web/app/future/(shared-page-wrapper)/(settings-layout)/layout.tsx create mode 100644 apps/web/app/future/apps/[slug]/layout.tsx rename apps/web/app/future/{(individual-page-wrapper) => }/apps/[slug]/page.tsx (100%) rename apps/web/app/future/{(individual-page-wrapper) => }/apps/[slug]/setup/page.tsx (100%) rename apps/web/app/future/{(individual-page-wrapper) => }/apps/categories/[category]/page.tsx (81%) rename apps/web/app/future/{(individual-page-wrapper) => }/apps/categories/page.tsx (78%) create mode 100644 apps/web/app/future/apps/installed/[category]/layout.tsx rename apps/web/app/future/{(individual-page-wrapper) => }/apps/installed/[category]/page.tsx (100%) rename apps/web/app/future/{(individual-page-wrapper) => }/bookings/[status]/layout.tsx (77%) rename apps/web/app/future/{(individual-page-wrapper) => }/bookings/[status]/page.tsx (100%) create mode 100644 apps/web/app/future/event-types/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(layout) => }/event-types/page.tsx (100%) rename apps/web/app/future/{(shared-page-wrapper)/(admin-layout) => }/settings/admin/apps/[category]/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/apps/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(admin-layout) => }/settings/admin/apps/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/flags/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(admin-layout) => }/settings/admin/flags/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/impersonation/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(admin-layout) => }/settings/admin/impersonation/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/oAuth/oAuthView/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(no-layout) => }/settings/admin/oAuth/oAuthView/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/oAuth/page.tsx create mode 100644 apps/web/app/future/settings/admin/organizations/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(settings-layout) => }/settings/admin/organizations/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/page.tsx rename apps/web/app/future/{(shared-page-wrapper)/(settings-layout) => }/settings/admin/users/[id]/edit/page.tsx (100%) rename apps/web/app/future/{(shared-page-wrapper)/(settings-layout) => }/settings/admin/users/add/page.tsx (100%) create mode 100644 apps/web/app/future/settings/admin/users/layout.tsx rename apps/web/app/future/{(shared-page-wrapper)/(settings-layout) => }/settings/admin/users/page.tsx (100%) rename apps/web/app/future/{(individual-page-wrapper) => }/teams/page.tsx (56%) rename apps/web/app/future/{(individual-page-wrapper) => }/video/[uid]/page.tsx (75%) rename apps/web/app/future/{(individual-page-wrapper) => }/video/meeting-ended/[uid]/page.tsx (59%) rename apps/web/app/future/{(individual-page-wrapper) => }/video/meeting-not-started/[uid]/page.tsx (63%) create mode 100644 apps/web/app/future/video/no-meeting-found/page.tsx create mode 100644 apps/web/app/layoutHOC.tsx diff --git a/apps/web/app/_types.ts b/apps/web/app/_types.ts index 91f01306f4be65..0e6ce75a137306 100644 --- a/apps/web/app/_types.ts +++ b/apps/web/app/_types.ts @@ -1,3 +1,14 @@ export type Params = { [param: string]: string | string[] | undefined; }; + +export type SearchParams = { + [param: string]: string | string[] | undefined; +}; + +export type PageProps = { + params: Params; + searchParams: SearchParams; +}; + +export type LayoutProps = { params: Params; children: React.ReactElement }; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx deleted file mode 100644 index 918ae3fa16f167..00000000000000 --- a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { type ReactElement } from "react"; - -import PageWrapper from "@components/PageWrapperAppDir"; - -type EventTypesLayoutProps = { - children: ReactElement; -}; - -export default function Layout({ children }: EventTypesLayoutProps) { - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/layout.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/layout.tsx deleted file mode 100644 index 918ae3fa16f167..00000000000000 --- a/apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { type ReactElement } from "react"; - -import PageWrapper from "@components/PageWrapperAppDir"; - -type EventTypesLayoutProps = { - children: ReactElement; -}; - -export default function Layout({ children }: EventTypesLayoutProps) { - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/layout.tsx deleted file mode 100644 index ef7d2abdf2f484..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { headers } from "next/headers"; -import { type ReactElement } from "react"; - -import PageWrapper from "@components/PageWrapperAppDir"; -import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; - -type WrapperWithLayoutProps = { - children: ReactElement; -}; - -export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/oAuth/page.tsx b/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/oAuth/page.tsx deleted file mode 100644 index 61cb362dba2a38..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/oAuth/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Page from "@pages/settings/admin/oAuth/index"; -import { _generateMetadata } from "app/_utils"; - -export const generateMetadata = async () => - await _generateMetadata( - () => "OAuth", - () => "Add new OAuth Clients" - ); - -export default Page; diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/page.tsx b/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/page.tsx deleted file mode 100644 index a45fe0a58de6ad..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Page from "@pages/settings/admin/index"; -import { _generateMetadata } from "app/_utils"; - -export const generateMetadata = async () => - await _generateMetadata( - () => "Admin", - () => "admin_description" - ); - -export default Page; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx deleted file mode 100644 index 7d78c3b4220c80..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(layout)/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// pages without layout (e.g., /availability/index.tsx) are supposed to go under (layout) folder -import { headers } from "next/headers"; -import { type ReactElement } from "react"; - -import { getLayout } from "@calcom/features/MainLayoutAppDir"; - -import PageWrapper from "@components/PageWrapperAppDir"; - -type WrapperWithLayoutProps = { - children: ReactElement; -}; - -export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/(shared-page-wrapper)/(no-layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(no-layout)/layout.tsx deleted file mode 100644 index c079ba0ad88fb4..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(no-layout)/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// pages containing layout (e.g., /availability/[schedule].tsx) are supposed to go under (no-layout) folder -import { headers } from "next/headers"; -import { type ReactElement } from "react"; - -import PageWrapper from "@components/PageWrapperAppDir"; - -type WrapperWithoutLayoutProps = { - children: ReactElement; -}; - -export default async function WrapperWithoutLayout({ children }: WrapperWithoutLayoutProps) { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/(shared-page-wrapper)/(no-layout)/video/no-meeting-found/page.tsx b/apps/web/app/future/(shared-page-wrapper)/(no-layout)/video/no-meeting-found/page.tsx deleted file mode 100644 index df1af7a2157fcb..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(no-layout)/video/no-meeting-found/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Page from "@pages/video/no-meeting-found"; -import { _generateMetadata } from "app/_utils"; - -export const generateMetadata = async () => - await _generateMetadata( - () => "", - () => "" - ); - -export default Page; diff --git a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/layout.tsx b/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/layout.tsx deleted file mode 100644 index 2cb530db2179d2..00000000000000 --- a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { headers } from "next/headers"; -import { type ReactElement } from "react"; - -import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; - -import PageWrapper from "@components/PageWrapperAppDir"; - -type WrapperWithLayoutProps = { - children: ReactElement; -}; - -export default async function WrapperWithLayout({ children }: WrapperWithLayoutProps) { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/future/apps/[slug]/layout.tsx b/apps/web/app/future/apps/[slug]/layout.tsx new file mode 100644 index 00000000000000..dc2fb3468fd901 --- /dev/null +++ b/apps/web/app/future/apps/[slug]/layout.tsx @@ -0,0 +1,3 @@ +import { WithLayout } from "app/layoutHOC"; + +export default WithLayout({ getLayout: null })<"L">; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/future/apps/[slug]/page.tsx similarity index 100% rename from apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx rename to apps/web/app/future/apps/[slug]/page.tsx diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx b/apps/web/app/future/apps/[slug]/setup/page.tsx similarity index 100% rename from apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx rename to apps/web/app/future/apps/[slug]/setup/page.tsx diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx b/apps/web/app/future/apps/categories/[category]/page.tsx similarity index 81% rename from apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx rename to apps/web/app/future/apps/categories/[category]/page.tsx index b58e845d83b2df..a4d8532821d300 100644 --- a/apps/web/app/future/(individual-page-wrapper)/apps/categories/[category]/page.tsx +++ b/apps/web/app/future/apps/categories/[category]/page.tsx @@ -1,6 +1,7 @@ import CategoryPage from "@pages/apps/categories/[category]"; import { Prisma } from "@prisma/client"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { notFound } from "next/navigation"; import z from "zod"; @@ -9,8 +10,6 @@ import { APP_NAME } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; import { AppCategories } from "@calcom/prisma/enums"; -import PageWrapper from "@components/PageWrapperAppDir"; - export const generateMetadata = async () => { return await _generateMetadata( () => `${APP_NAME} | ${APP_NAME}`, @@ -67,13 +66,6 @@ const getPageProps = async ({ params }: { params: Record }) { - const { apps } = await getPageProps({ params }); - return ( - - - - ); -} - +// @ts-expect-error getData arg +export default WithLayout({ getData: getPageProps, Page: CategoryPage })

    ; export const dynamic = "force-static"; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx b/apps/web/app/future/apps/categories/page.tsx similarity index 78% rename from apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx rename to apps/web/app/future/apps/categories/page.tsx index c0d6c3d15e714e..c878d3773279b3 100644 --- a/apps/web/app/future/(individual-page-wrapper)/apps/categories/page.tsx +++ b/apps/web/app/future/apps/categories/page.tsx @@ -1,14 +1,13 @@ import LegacyPage from "@pages/apps/categories/index"; import { ssrInit } from "app/_trpc/ssrInit"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { cookies, headers } from "next/headers"; import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { APP_NAME } from "@calcom/lib/constants"; -import PageWrapper from "@components/PageWrapperAppDir"; - export const generateMetadata = async () => { return await _generateMetadata( () => `Categories | ${APP_NAME}`, @@ -43,14 +42,4 @@ async function getPageProps() { }; } -export default async function Page() { - const props = await getPageProps(); - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - return ( - - - - ); -} +export default WithLayout({ getData: getPageProps, Page: LegacyPage, getLayout: null })<"P">; diff --git a/apps/web/app/future/apps/installed/[category]/layout.tsx b/apps/web/app/future/apps/installed/[category]/layout.tsx new file mode 100644 index 00000000000000..dc2fb3468fd901 --- /dev/null +++ b/apps/web/app/future/apps/installed/[category]/layout.tsx @@ -0,0 +1,3 @@ +import { WithLayout } from "app/layoutHOC"; + +export default WithLayout({ getLayout: null })<"L">; diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/page.tsx b/apps/web/app/future/apps/installed/[category]/page.tsx similarity index 100% rename from apps/web/app/future/(individual-page-wrapper)/apps/installed/[category]/page.tsx rename to apps/web/app/future/apps/installed/[category]/page.tsx diff --git a/apps/web/app/future/(individual-page-wrapper)/bookings/[status]/layout.tsx b/apps/web/app/future/bookings/[status]/layout.tsx similarity index 77% rename from apps/web/app/future/(individual-page-wrapper)/bookings/[status]/layout.tsx rename to apps/web/app/future/bookings/[status]/layout.tsx index ad00ceeb781a1c..7391f9996f3235 100644 --- a/apps/web/app/future/(individual-page-wrapper)/bookings/[status]/layout.tsx +++ b/apps/web/app/future/bookings/[status]/layout.tsx @@ -1,6 +1,7 @@ import { ssgInit } from "app/_trpc/ssgInit"; import type { Params } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { notFound } from "next/navigation"; import type { ReactElement } from "react"; import { z } from "zod"; @@ -8,8 +9,6 @@ import { z } from "zod"; import { getLayout } from "@calcom/features/MainLayoutAppDir"; import { APP_NAME } from "@calcom/lib/constants"; -import PageWrapper from "@components/PageWrapperAppDir"; - const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const; const querySchema = z.object({ @@ -43,14 +42,6 @@ const getData = async ({ params }: { params: Params }) => { }; }; -export default async function BookingPageLayout({ params, children }: Props) { - const props = await getData({ params }); - - return ( - - {children} - - ); -} +export default WithLayout({ getLayout, getData })<"L">; export const dynamic = "force-static"; diff --git a/apps/web/app/future/(individual-page-wrapper)/bookings/[status]/page.tsx b/apps/web/app/future/bookings/[status]/page.tsx similarity index 100% rename from apps/web/app/future/(individual-page-wrapper)/bookings/[status]/page.tsx rename to apps/web/app/future/bookings/[status]/page.tsx diff --git a/apps/web/app/future/event-types/layout.tsx b/apps/web/app/future/event-types/layout.tsx new file mode 100644 index 00000000000000..9bf51a70f525dc --- /dev/null +++ b/apps/web/app/future/event-types/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/MainLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx b/apps/web/app/future/event-types/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(layout)/event-types/page.tsx rename to apps/web/app/future/event-types/page.tsx diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/apps/[category]/page.tsx b/apps/web/app/future/settings/admin/apps/[category]/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/apps/[category]/page.tsx rename to apps/web/app/future/settings/admin/apps/[category]/page.tsx diff --git a/apps/web/app/future/settings/admin/apps/layout.tsx b/apps/web/app/future/settings/admin/apps/layout.tsx new file mode 100644 index 00000000000000..f415bae7649c1e --- /dev/null +++ b/apps/web/app/future/settings/admin/apps/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/apps/page.tsx b/apps/web/app/future/settings/admin/apps/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/apps/page.tsx rename to apps/web/app/future/settings/admin/apps/page.tsx diff --git a/apps/web/app/future/settings/admin/flags/layout.tsx b/apps/web/app/future/settings/admin/flags/layout.tsx new file mode 100644 index 00000000000000..f415bae7649c1e --- /dev/null +++ b/apps/web/app/future/settings/admin/flags/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/flags/page.tsx b/apps/web/app/future/settings/admin/flags/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/flags/page.tsx rename to apps/web/app/future/settings/admin/flags/page.tsx diff --git a/apps/web/app/future/settings/admin/impersonation/layout.tsx b/apps/web/app/future/settings/admin/impersonation/layout.tsx new file mode 100644 index 00000000000000..f415bae7649c1e --- /dev/null +++ b/apps/web/app/future/settings/admin/impersonation/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/impersonation/page.tsx b/apps/web/app/future/settings/admin/impersonation/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(admin-layout)/settings/admin/impersonation/page.tsx rename to apps/web/app/future/settings/admin/impersonation/page.tsx diff --git a/apps/web/app/future/settings/admin/oAuth/oAuthView/layout.tsx b/apps/web/app/future/settings/admin/oAuth/oAuthView/layout.tsx new file mode 100644 index 00000000000000..dc2fb3468fd901 --- /dev/null +++ b/apps/web/app/future/settings/admin/oAuth/oAuthView/layout.tsx @@ -0,0 +1,3 @@ +import { WithLayout } from "app/layoutHOC"; + +export default WithLayout({ getLayout: null })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(no-layout)/settings/admin/oAuth/oAuthView/page.tsx b/apps/web/app/future/settings/admin/oAuth/oAuthView/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(no-layout)/settings/admin/oAuth/oAuthView/page.tsx rename to apps/web/app/future/settings/admin/oAuth/oAuthView/page.tsx diff --git a/apps/web/app/future/settings/admin/oAuth/page.tsx b/apps/web/app/future/settings/admin/oAuth/page.tsx new file mode 100644 index 00000000000000..58a8a41dd6bab7 --- /dev/null +++ b/apps/web/app/future/settings/admin/oAuth/page.tsx @@ -0,0 +1,13 @@ +import LegacyPage from "@pages/settings/admin/oAuth/index"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; + +export const generateMetadata = async () => + await _generateMetadata( + () => "OAuth", + () => "Add new OAuth Clients" + ); + +export default WithLayout({ getLayout, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/settings/admin/organizations/layout.tsx b/apps/web/app/future/settings/admin/organizations/layout.tsx new file mode 100644 index 00000000000000..1359b266012440 --- /dev/null +++ b/apps/web/app/future/settings/admin/organizations/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/organizations/page.tsx b/apps/web/app/future/settings/admin/organizations/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/organizations/page.tsx rename to apps/web/app/future/settings/admin/organizations/page.tsx diff --git a/apps/web/app/future/settings/admin/page.tsx b/apps/web/app/future/settings/admin/page.tsx new file mode 100644 index 00000000000000..cfc6e0aeec6523 --- /dev/null +++ b/apps/web/app/future/settings/admin/page.tsx @@ -0,0 +1,13 @@ +import LegacyPage from "@pages/settings/admin/index"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@components/auth/layouts/AdminLayoutAppDir"; + +export const generateMetadata = async () => + await _generateMetadata( + () => "Admin", + () => "admin_description" + ); + +export default WithLayout({ getLayout, Page: LegacyPage })<"P">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/[id]/edit/page.tsx b/apps/web/app/future/settings/admin/users/[id]/edit/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/[id]/edit/page.tsx rename to apps/web/app/future/settings/admin/users/[id]/edit/page.tsx diff --git a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/add/page.tsx b/apps/web/app/future/settings/admin/users/add/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/add/page.tsx rename to apps/web/app/future/settings/admin/users/add/page.tsx diff --git a/apps/web/app/future/settings/admin/users/layout.tsx b/apps/web/app/future/settings/admin/users/layout.tsx new file mode 100644 index 00000000000000..1359b266012440 --- /dev/null +++ b/apps/web/app/future/settings/admin/users/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout })<"L">; diff --git a/apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/page.tsx b/apps/web/app/future/settings/admin/users/page.tsx similarity index 100% rename from apps/web/app/future/(shared-page-wrapper)/(settings-layout)/settings/admin/users/page.tsx rename to apps/web/app/future/settings/admin/users/page.tsx diff --git a/apps/web/app/future/(individual-page-wrapper)/teams/page.tsx b/apps/web/app/future/teams/page.tsx similarity index 56% rename from apps/web/app/future/(individual-page-wrapper)/teams/page.tsx rename to apps/web/app/future/teams/page.tsx index 18059f5b99e0c8..0f19dd152361a6 100644 --- a/apps/web/app/future/(individual-page-wrapper)/teams/page.tsx +++ b/apps/web/app/future/teams/page.tsx @@ -1,28 +1,19 @@ import OldPage from "@pages/teams/index"; import { ssrInit } from "app/_trpc/ssrInit"; -import { type Params } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { type GetServerSidePropsContext } from "next"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; import { getLayout } from "@calcom/features/MainLayoutAppDir"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; - -import PageWrapper from "@components/PageWrapperAppDir"; - export const generateMetadata = async () => await _generateMetadata( (t) => t("teams"), (t) => t("create_manage_teams_collaborative") ); -type PageProps = { - params: Params; -}; - async function getData(context: Omit) { const ssr = await ssrInit(); await ssr.viewer.me.prefetch(); @@ -41,24 +32,5 @@ async function getData(context: Omit { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - const legacyCtx = buildLegacyCtx(h, cookies(), params); - // @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext` - const props = await getData(legacyCtx); - - return ( - - - - ); -}; - -export default Page; +// @ts-expect-error getData arg +export default WithLayout({ getData, getLayout, Page: OldPage })<"P">; diff --git a/apps/web/app/future/(individual-page-wrapper)/video/[uid]/page.tsx b/apps/web/app/future/video/[uid]/page.tsx similarity index 75% rename from apps/web/app/future/(individual-page-wrapper)/video/[uid]/page.tsx rename to apps/web/app/future/video/[uid]/page.tsx index e1e473ceecf9ac..b7813764237993 100644 --- a/apps/web/app/future/(individual-page-wrapper)/video/[uid]/page.tsx +++ b/apps/web/app/future/video/[uid]/page.tsx @@ -1,30 +1,21 @@ import OldPage from "@pages/video/[uid]"; import { ssrInit } from "app/_trpc/ssrInit"; -import { type Params } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import MarkdownIt from "markdown-it"; import { type GetServerSidePropsContext } from "next"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { APP_NAME } from "@calcom/lib/constants"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; - -import PageWrapper from "@components/PageWrapperAppDir"; - export const generateMetadata = async () => await _generateMetadata( () => `${APP_NAME} Video`, (t) => t("quick_video_meeting") ); -type PageProps = Readonly<{ - params: Params; -}>; - const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); async function getData(context: Omit) { @@ -107,24 +98,5 @@ async function getData(context: Omit { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - const legacyCtx = buildLegacyCtx(headers(), cookies(), params); - // @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext` - const { dehydratedState, ...restProps } = await getData(legacyCtx); - - return ( - - - - ); -}; - -export default Page; +// @ts-expect-error getData arg +export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">; diff --git a/apps/web/app/future/(individual-page-wrapper)/video/meeting-ended/[uid]/page.tsx b/apps/web/app/future/video/meeting-ended/[uid]/page.tsx similarity index 59% rename from apps/web/app/future/(individual-page-wrapper)/video/meeting-ended/[uid]/page.tsx rename to apps/web/app/future/video/meeting-ended/[uid]/page.tsx index d0456d3047f11a..0674d39566546f 100644 --- a/apps/web/app/future/(individual-page-wrapper)/video/meeting-ended/[uid]/page.tsx +++ b/apps/web/app/future/video/meeting-ended/[uid]/page.tsx @@ -1,26 +1,17 @@ import OldPage from "@pages/video/meeting-ended/[uid]"; -import { type Params } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { type GetServerSidePropsContext } from "next"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; - -import PageWrapper from "@components/PageWrapperAppDir"; - export const generateMetadata = async () => await _generateMetadata( () => "Meeting Unavailable", () => "Meeting Unavailable" ); -type PageProps = Readonly<{ - params: Params; -}>; - async function getData(context: Omit) { const booking = await prisma.booking.findUnique({ where: { @@ -58,19 +49,5 @@ async function getData(context: Omit { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - const legacyCtx = buildLegacyCtx(headers(), cookies(), params); - // @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext` - const props = await getData(legacyCtx); - - return ( - - - - ); -}; - -export default Page; +// @ts-expect-error getData arg +export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">; diff --git a/apps/web/app/future/(individual-page-wrapper)/video/meeting-not-started/[uid]/page.tsx b/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx similarity index 63% rename from apps/web/app/future/(individual-page-wrapper)/video/meeting-not-started/[uid]/page.tsx rename to apps/web/app/future/video/meeting-not-started/[uid]/page.tsx index b15f16452efbf7..bde0c18328cf5c 100644 --- a/apps/web/app/future/(individual-page-wrapper)/video/meeting-not-started/[uid]/page.tsx +++ b/apps/web/app/future/video/meeting-not-started/[uid]/page.tsx @@ -1,16 +1,12 @@ import OldPage from "@pages/video/meeting-not-started/[uid]"; import { type Params } from "app/_types"; import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; import { type GetServerSidePropsContext } from "next"; -import { headers, cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; - -import PageWrapper from "@components/PageWrapperAppDir"; - type PageProps = Readonly<{ params: Params; }>; @@ -51,19 +47,5 @@ async function getData(context: Omit { - const h = headers(); - const nonce = h.get("x-nonce") ?? undefined; - - const legacyCtx = buildLegacyCtx(headers(), cookies(), params); - // @ts-expect-error `req` of type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to `req` in `GetServerSidePropsContext` - const props = await getData(legacyCtx); - - return ( - - - - ); -}; - -export default Page; +// @ts-expect-error getData arg +export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">; diff --git a/apps/web/app/future/video/no-meeting-found/page.tsx b/apps/web/app/future/video/no-meeting-found/page.tsx new file mode 100644 index 00000000000000..31a15a2ad69f6f --- /dev/null +++ b/apps/web/app/future/video/no-meeting-found/page.tsx @@ -0,0 +1,20 @@ +import LegacyPage from "@pages/video/no-meeting-found"; +import { ssrInit } from "app/_trpc/ssrInit"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("no_meeting_found"), + (t) => t("no_meeting_found") + ); + +const getData = async () => { + const ssr = await ssrInit(); + + return { + dehydratedState: await ssr.dehydrate(), + }; +}; + +export default WithLayout({ getData, Page: LegacyPage, getLayout: null })<"P">; diff --git a/apps/web/app/layoutHOC.tsx b/apps/web/app/layoutHOC.tsx new file mode 100644 index 00000000000000..5e41ed11840789 --- /dev/null +++ b/apps/web/app/layoutHOC.tsx @@ -0,0 +1,28 @@ +import type { LayoutProps, PageProps } from "app/_types"; +import { cookies, headers } from "next/headers"; + +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +import PageWrapper from "@components/PageWrapperAppDir"; + +type WithLayoutParams> = { + getLayout: ((page: React.ReactElement) => React.ReactNode) | null; + Page?: (props: T) => React.ReactElement; + getData?: (arg: ReturnType) => Promise; +}; + +export function WithLayout>({ getLayout, getData, Page }: WithLayoutParams) { + return async

    (p: P extends "P" ? PageProps : LayoutProps) => { + const h = headers(); + const nonce = h.get("x-nonce") ?? undefined; + const props = getData ? await getData(buildLegacyCtx(h, cookies(), p.params)) : ({} as T); + + const children = "children" in p ? p.children : null; + + return ( + + {Page ? : children} + + ); + }; +} diff --git a/apps/web/components/PageWrapperAppDir.tsx b/apps/web/components/PageWrapperAppDir.tsx index 08d7b9cad315b6..ae36417c97188c 100644 --- a/apps/web/components/PageWrapperAppDir.tsx +++ b/apps/web/components/PageWrapperAppDir.tsx @@ -20,7 +20,7 @@ export interface CalPageWrapper { export type PageWrapperProps = Readonly<{ getLayout: ((page: React.ReactElement) => ReactNode) | null; - children: React.ReactElement; + children: React.ReactNode; requiresLicense: boolean; nonce: string | undefined; themeBasis: string | null; @@ -62,7 +62,7 @@ function PageWrapper(props: PageWrapperProps) { dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }} /> {getLayout( - props.requiresLicense ? {props.children} : props.children + props.requiresLicense ? {props.children} : <>{props.children} )} From 49eaea4722f1db0f6a921eed5d301346e6a063e5 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Thu, 4 Jan 2024 17:55:10 +0000 Subject: [PATCH 41/42] Migrate d/* page group --- apps/web/app/future/d/[link]/[slug]/page.tsx | 113 +++++++++++++++++++ apps/web/pages/d/[link]/[slug].tsx | 2 + 2 files changed, 115 insertions(+) create mode 100644 apps/web/app/future/d/[link]/[slug]/page.tsx diff --git a/apps/web/app/future/d/[link]/[slug]/page.tsx b/apps/web/app/future/d/[link]/[slug]/page.tsx new file mode 100644 index 00000000000000..e51b6334a977bc --- /dev/null +++ b/apps/web/app/future/d/[link]/[slug]/page.tsx @@ -0,0 +1,113 @@ +import LegacyPage from "@pages/d/[link]/[slug]"; +import { ssrInit } from "app/_trpc/ssrInit"; +import { _generateMetadata } from "app/_utils"; +import { WithLayout } from "app/layoutHOC"; +import type { GetServerSidePropsContext } from "next"; +import { notFound } from "next/navigation"; +import { z } from "zod"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; +import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("appearance"), + (t) => t("appearance_description") + ); + +async function getPageProps(context: GetServerSidePropsContext) { + const ssr = await ssrInit(); + + const session = await getServerSession({ req: context.req }); + const { link, slug } = paramsSchema.parse(context.params); + const { rescheduleUid, duration: queryDuration } = context.query; + const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req); + const org = isValidOrgDomain ? currentOrgDomain : null; + + const hashedLink = await prisma.hashedLink.findUnique({ + where: { + link, + }, + select: { + eventTypeId: true, + eventType: { + select: { + users: { + select: { + username: true, + }, + }, + team: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + + const username = hashedLink?.eventType.users[0]?.username; + + if (!hashedLink || !username) { + return notFound(); + } + + const user = await prisma.user.findFirst({ + where: { + username, + organization: isValidOrgDomain + ? { + slug: currentOrgDomain, + } + : null, + }, + select: { + away: true, + hideBranding: true, + }, + }); + + if (!user) { + return notFound(); + } + + let booking: GetBookingType | null = null; + if (rescheduleUid) { + booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); + } + + const isTeamEvent = !!hashedLink.eventType?.team?.id; + + // We use this to both prefetch the query on the server, + // as well as to check if the event exist, so we c an show a 404 otherwise. + const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent, org }); + + if (!eventData) { + return notFound(); + } + + return { + entity: eventData.entity, + duration: getMultipleDurationValue(eventData.metadata?.multipleDuration, queryDuration, eventData.length), + booking, + away: user?.away, + user: username, + slug, + dehydratedState: await ssr.dehydrate(), + isBrandingHidden: user?.hideBranding, + // Sending the team event from the server, because this template file + // is reused for both team and user events. + isTeamEvent, + hashedLink: link, + }; +} + +const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) }); + +// @ts-expect-error getData arg +export default WithLayout({ getLayout: null, Page: LegacyPage, getData: getPageProps }); diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 58cae2a8aff76a..ecbe792d8c59a3 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -1,3 +1,5 @@ +"use client"; + import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; From b8398cc4a2b56ee30ff022f0cefe541b643f3a8c Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Thu, 4 Jan 2024 18:07:56 +0000 Subject: [PATCH 42/42] Fix metadata --- apps/web/app/future/d/[link]/[slug]/page.tsx | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/app/future/d/[link]/[slug]/page.tsx b/apps/web/app/future/d/[link]/[slug]/page.tsx index e51b6334a977bc..e70bd9543d9923 100644 --- a/apps/web/app/future/d/[link]/[slug]/page.tsx +++ b/apps/web/app/future/d/[link]/[slug]/page.tsx @@ -3,6 +3,7 @@ import { ssrInit } from "app/_trpc/ssrInit"; import { _generateMetadata } from "app/_utils"; import { WithLayout } from "app/layoutHOC"; import type { GetServerSidePropsContext } from "next"; +import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; import { z } from "zod"; @@ -12,12 +13,27 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; +import { trpc } from "@calcom/trpc/react"; -export const generateMetadata = async () => - await _generateMetadata( - (t) => t("appearance"), - (t) => t("appearance_description") +import { buildLegacyCtx } from "@lib/buildLegacyCtx"; + +export const generateMetadata = async ({ params }: { params: Record }) => { + // @ts-expect-error getPageProps arg + const pageProps = await getPageProps(buildLegacyCtx(headers(), cookies(), params)); + + const { entity, booking, user, slug, isTeamEvent } = pageProps; + const rescheduleUid = booking?.uid; + const { data: event } = trpc.viewer.public.event.useQuery( + { username: user, eventSlug: slug, isTeamEvent, org: entity.orgSlug ?? null }, + { refetchOnWindowFocus: false } + ); + const profileName = event?.profile?.name ?? ""; + const title = event?.title ?? ""; + return await _generateMetadata( + (t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`, + (t) => `${rescheduleUid ? t("reschedule") : ""} ${title}` ); +}; async function getPageProps(context: GetServerSidePropsContext) { const ssr = await ssrInit();