From e5e0fa97eb3a75769d0bba62c8111ee577cade9b Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 19 Dec 2023 15:03:30 +0530 Subject: [PATCH] fix: Across Org Scenarios - Wrong links for event and team (#12358) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- .../web/components/eventtype/EventTeamTab.tsx | 1 + .../eventtype/EventTypeSingleLayout.tsx | 10 +- apps/web/components/team/screens/Team.tsx | 4 +- apps/web/pages/event-types/[type]/index.tsx | 1 + apps/web/pages/event-types/index.tsx | 52 ++--- apps/web/pages/team/[slug].tsx | 5 +- apps/web/playwright/fixtures/users.ts | 8 +- .../organization/across-org/across-org.e2e.ts | 101 +++++++++ .../utils/bookingScenario/bookingScenario.ts | 10 +- .../web/test/utils/bookingScenario/expects.ts | 2 +- .../pages/route-builder/[...appPages].tsx | 4 +- .../features/bookings/lib/handleNewBooking.ts | 7 +- .../collective-scheduling.test.ts | 214 ++++++++++++++++++ .../ee/organizations/lib/orgDomains.ts | 2 +- .../pages/components/MemberListItem.tsx | 15 +- .../ee/teams/components/AddNewTeamMembers.tsx | 20 +- .../ee/teams/components/MemberListItem.tsx | 3 +- .../ee/teams/components/TeamListItem.tsx | 9 +- .../ee/teams/pages/team-profile-view.tsx | 17 +- .../components/ChildrenEventTypeSelect.tsx | 8 +- packages/lib/getAvatarUrl.ts | 16 +- packages/lib/getBookerUrl/client.ts | 30 +++ packages/lib/getBookerUrl/server.ts | 12 + packages/lib/getEventTypeById.ts | 15 ++ packages/lib/server/getBookerUrl.ts | 7 - packages/lib/server/queries/teams/index.ts | 4 +- .../teamsAndUserProfilesQuery.handler.ts | 24 +- .../bookings/requestReschedule.handler.ts | 17 +- .../viewer/eventTypes/getByViewer.handler.ts | 70 ++++-- .../viewer/eventTypes/getByViewer.schema.ts | 1 + .../listOtherTeamMembers.handler.ts | 10 +- .../routers/viewer/teams/get.handler.ts | 9 +- .../routers/viewer/teams/list.handler.ts | 1 + .../viewer/webhook/getByViewer.handler.ts | 4 +- .../components/createButton/CreateButton.tsx | 11 +- 35 files changed, 570 insertions(+), 154 deletions(-) create mode 100644 apps/web/playwright/organization/across-org/across-org.e2e.ts create mode 100644 packages/lib/getBookerUrl/client.ts create mode 100644 packages/lib/getBookerUrl/server.ts delete mode 100644 packages/lib/server/getBookerUrl.ts diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index 014fde0ed1..5a8e91bcb7 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -44,6 +44,7 @@ export const mapMemberToChildrenOption = ( username: member.username ?? "", membership: member.membership, eventTypeSlugs: member.eventTypes ?? [], + avatar: member.avatar, }, value: `${member.id ?? ""}`, label: `${member.name || member.email || ""}${!member.username ? ` (${pendingString})` : ""}`, diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index f802055a01..a618d5e793 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -7,11 +7,9 @@ import { useMemo, useState, Suspense } from "react"; import type { UseFormReturn } from "react-hook-form"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; -import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed"; import Shell from "@calcom/features/shell/Shell"; import { classNames } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -67,6 +65,7 @@ type Props = { isUpdateMutationLoading?: boolean; availability?: AvailabilityOption; isUserOrganizationAdmin: boolean; + bookerUrl: string; }; function getNavigation(props: { @@ -135,6 +134,7 @@ function EventTypeSingleLayout({ formMethods, availability, isUserOrganizationAdmin, + bookerUrl, }: Props) { const utils = trpc.useContext(); const { t } = useLocale(); @@ -235,10 +235,8 @@ function EventTypeSingleLayout({ formMethods, ]); - const orgBranding = useOrgBranding(); - const isOrgEvent = orgBranding?.fullDomain; - const permalink = `${orgBranding?.fullDomain ?? CAL_URL}/${ - team ? `${!isOrgEvent ? "team/" : ""}${team.slug}` : eventType.users[0].username + const permalink = `${bookerUrl}/${ + team ? `${!team.parentId ? "team/" : ""}${team.slug}` : eventType.users[0].username }/${eventType.slug}`; const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`; diff --git a/apps/web/components/team/screens/Team.tsx b/apps/web/components/team/screens/Team.tsx index aa3673cd42..30c935f8de 100644 --- a/apps/web/components/team/screens/Team.tsx +++ b/apps/web/components/team/screens/Team.tsx @@ -12,7 +12,7 @@ type TeamType = Omit, "inviteToken">; type MembersType = TeamType["members"]; type MemberType = Pick & { safeBio: string | null; - orgOrigin: string; + bookerUrl: string; }; const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => { @@ -26,7 +26,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n return ( + href={{ pathname: `${member.bookerUrl}/${member.username}`, query: queryParamsToForward }}>
diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index a188965bbd..b301363bfc 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -522,6 +522,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { // disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"} disableBorder={true} currentUserMembership={currentUserMembership} + bookerUrl={eventType.bookerUrl} isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
= (props) => { types={events[0].eventTypes} group={events[0]} groupIndex={0} + bookerUrl={events[0].bookerUrl} readOnly={events[0].metadata.readOnly} /> ) : ( @@ -207,12 +206,17 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou const MemoizedItem = memo(Item); -export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => { +export const EventTypeList = ({ + group, + groupIndex, + readOnly, + types, + bookerUrl, +}: EventTypeListProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); const pathname = usePathname(); const searchParams = useCompatSearchParams(); - const orgBranding = useOrgBranding(); const [parent] = useAutoAnimate(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0); @@ -383,7 +387,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
    {types.map((type, index) => { const embedLink = `${group.profile.slug}/${type.slug}`; - const calLink = `${orgBranding?.fullDomain ?? CAL_URL}/${embedLink}`; + const calLink = `${bookerUrl}/${embedLink}`; const isManagedEventType = type.schedulingType === SchedulingType.MANAGED; const isChildrenManagedEventType = type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED; @@ -410,17 +414,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL /> )} {isManagedEventType && type?.children && type.children?.length > 0 && ( - ch.users) - .map((user: Pick) => ({ - alt: user.name || "", - image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${user.username}/avatar.png`, - title: user.name || "", - }))} + users={type?.children.flatMap((ch) => ch.users) ?? []} /> )}
    @@ -697,10 +695,10 @@ const EventTypeListHeading = ({ profile, membershipCount, teamId, + bookerUrl, }: EventTypeListHeadingProps): JSX.Element => { const { t } = useLocale(); const router = useRouter(); - const orgBranding = useOrgBranding(); const publishTeamMutation = trpc.viewer.teams.publish.useMutation({ onSuccess(data) { @@ -710,18 +708,13 @@ const EventTypeListHeading = ({ showToast(error.message, "error"); }, }); - const bookerUrl = useBookerUrl(); return (
    @@ -742,9 +735,7 @@ const EventTypeListHeading = ({ )} {profile?.slug && ( - + {`${bookerUrl.replace("https://", "").replace("http://", "")}/${profile.slug}`} )} @@ -865,18 +856,22 @@ const Main = ({ ) : ( data.eventTypeGroups.map((group: EventTypeGroup, index: number) => ( -
    +
    {group.eventTypes.length ? ( @@ -895,6 +890,7 @@ const Main = ({ types={data.eventTypeGroups[0].eventTypes} group={data.eventTypeGroups[0]} groupIndex={0} + bookerUrl={data.eventTypeGroups[0].bookerUrl} readOnly={data.eventTypeGroups[0].metadata.readOnly} /> ) diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 88fca5f179..a5cdaca7fa 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -11,10 +11,11 @@ import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe"; -import { orgDomainConfig, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import useTheme from "@calcom/lib/hooks/useTheme"; @@ -364,7 +365,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => accepted: member.accepted, organizationId: member.organizationId, safeBio: markdownToSafeHTML(member.bio || ""), - orgOrigin: getOrgFullOrigin(member.organization?.slug || ""), + bookerUrl: getBookerBaseUrlSync(member.organization?.slug || ""), }; }) : []; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 1d45c989b0..3414a03724 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -491,13 +491,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { }, }, }, - include: { - team: { - include: { - children: true, - }, - }, - }, + include: { team: { include: { children: true } } }, }); }, getFirstEventAsOwner: 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 new file mode 100644 index 0000000000..14012d4da6 --- /dev/null +++ b/apps/web/playwright/organization/across-org/across-org.e2e.ts @@ -0,0 +1,101 @@ +import { expect } from "@playwright/test"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import { test } from "../../lib/fixtures"; + +test.afterAll(({ users, emails }) => { + users.deleteAll(); + emails?.deleteAll(); +}); + +test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => { + test("Team1 profile should show correct domain if logged in as User1", async ({ page, users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + + const user1NotMemberOfOrg1 = await users.create(undefined, { + hasTeam: true, + }); + + const { team: team1MemberOfOrg1 } = await user1NotMemberOfOrg1.getFirstTeamMembership(); + await moveTeamToOrg({ team: team1MemberOfOrg1, org }); + + await user1NotMemberOfOrg1.apiLogin(); + + await page.goto(`/settings/teams/${team1MemberOfOrg1.id}/profile`); + const domain = await page.locator(".testid-leading-text-team-url").textContent(); + expect(domain).toContain(org.slug); + }); + + test("EventTypes listing should show correct link for user events and team1MemberOfOrg1's events", async ({ + page, + users, + orgs, + }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + + const user1NotMemberOfOrg1 = await users.create(undefined, { + hasTeam: true, + }); + + const { team: team1MemberOfOrg1 } = await user1NotMemberOfOrg1.getFirstTeamMembership(); + await moveTeamToOrg({ team: team1MemberOfOrg1, org }); + + await user1NotMemberOfOrg1.apiLogin(); + await page.goto("/event-types"); + await page.waitForLoadState("networkidle"); + + const userEventLinksLocators = await page + .locator(`[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`) + .all(); + + expect(userEventLinksLocators.length).toBeGreaterThan(0); + + for (const userEventLinkLocator of userEventLinksLocators) { + const href = await userEventLinkLocator.getAttribute("href"); + expect(href).toContain(WEBAPP_URL); + } + + const teamEventLinksLocators = await page + .locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`) + .all(); + + expect(teamEventLinksLocators.length).toBeGreaterThan(0); + + for (const teamEventLinksLocator of teamEventLinksLocators) { + const href = await teamEventLinksLocator.getAttribute("href"); + expect(href).not.toContain(WEBAPP_URL); + expect(href).toContain(org.slug); + } + }); +}); + +async function moveTeamToOrg({ + team, + org, +}: { + team: { + id: number; + }; + org: { + id: number; + }; +}) { + await prisma.team.update({ + where: { + id: team.id, + }, + data: { + parent: { + connect: { + id: org.id, + }, + }, + }, + }); +} diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 63ab59c9df..d87c665d14 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -39,7 +39,7 @@ type InputWebhook = { /** * Data to be mocked */ -type ScenarioData = { +export type ScenarioData = { // hosts: { id: number; eventTypeId?: number; userId?: number; isFixed?: boolean }[]; /** * Prisma would return these eventTypes @@ -835,10 +835,12 @@ export function getOrganizer({ defaultScheduleId, weekStart = "Sunday", teams, + organizationId, }: { name: string; email: string; id: number; + organizationId?: number | null; schedules: InputUser["schedules"]; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; @@ -849,7 +851,6 @@ export function getOrganizer({ }) { return { ...TestData.users.example, - organizationId: null as null | number, name, email, id, @@ -860,6 +861,7 @@ export function getOrganizer({ defaultScheduleId, weekStart, teams, + organizationId, }; } @@ -904,6 +906,7 @@ export function getScenarioData( eventTypes: eventTypes.map((eventType, index) => { return { ...eventType, + teamId: eventType.teamId || null, title: `Test Event Type - ${index + 1}`, description: `It's a test event type - ${index + 1}`, }; @@ -911,6 +914,7 @@ export function getScenarioData( users: users.map((user) => { const newUser = { ...user, + organizationId: user.organizationId ?? null, }; if (user.destinationCalendar) { newUser.destinationCalendar = { @@ -924,7 +928,7 @@ export function getScenarioData( apps: [...apps], webhooks, bookings: bookings || [], - }; + } satisfies ScenarioData; } export function enableEmailFeature() { diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 5129338ec2..263b706775 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -136,7 +136,7 @@ expect.extend({ return { pass: false, - message: () => `Email content ${isNot ? "is" : "is not"} matching`, + message: () => `Email content ${isNot ? "is" : "is not"} matching. ${JSON.stringify(emailsToLog)}`, actual: actualEmailContent, expected: expectedEmailContent, }; diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 07b43aa06d..6cb4613ccb 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -92,7 +92,9 @@ const Route = ({ }) => { const index = routes.indexOf(route); - const { data: eventTypesByGroup } = trpc.viewer.eventTypes.getByViewer.useQuery(); + const { data: eventTypesByGroup } = trpc.viewer.eventTypes.getByViewer.useQuery({ + forRoutingForms: true, + }); const eventOptions: { label: string; value: string }[] = []; eventTypesByGroup?.eventTypeGroups.forEach((group) => { diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 6a025000da..6deee33c56 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -60,6 +60,7 @@ import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; @@ -69,7 +70,6 @@ import { handlePayment } from "@calcom/lib/payment/handlePayment"; import { getPiiFreeCalendarEvent, getPiiFreeEventType, getPiiFreeUser } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { checkBookingLimits, checkDurationLimits, getLuckyUser } from "@calcom/lib/server"; -import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; import { getTranslation } from "@calcom/lib/server/i18n"; import { slugify } from "@calcom/lib/slugify"; import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; @@ -274,6 +274,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { select: { id: true, name: true, + parentId: true, }, }, bookingFields: true, @@ -1272,7 +1273,9 @@ async function handler( const iCalSequence = getICalSequence(originalRescheduledBooking); let evt: CalendarEvent = { - bookerUrl: await getBookerUrl(organizerUser), + bookerUrl: eventType.team + ? await getBookerBaseUrl({ organizationId: eventType.team.parentId }) + : await getBookerBaseUrl(organizerUser), type: eventType.slug, title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately description: eventType.description, 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 eb59eed52e..89531c5cee 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 @@ -8,6 +8,7 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; +import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createBookingScenario, getGoogleCalendarCredential, @@ -1085,6 +1086,219 @@ describe("handleNewBooking", () => { }, timeout ); + + describe("Team(T1) not part of any org but the organizer is part of an organization(O1)", () => { + test( + `succesfully creates a booking when all the hosts are free as per their schedules + - Destination calendars for event-type and non-first hosts are used to create calendar events + - Reschedule and Cancel link in email are not of the org domain because the team is not part of any org + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + organizationId: org.id, + teams: [ + { + membership: { + accepted: true, + }, + team: { + id: 1, + name: "Team 1", + slug: "team-1", + }, + }, + ], + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // It is a team event but that team isn't part of any org + teamId: 1, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + 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, + }, + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + }, + timeout + ); + }); }); test.todo("Round Robin booking"); diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index d21bb8d8c1..b8f3608913 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -95,7 +95,7 @@ export function subdomainSuffix() { } export function getOrgFullOrigin(slug: string, options: { protocol: boolean } = { protocol: true }) { - if (!slug) return WEBAPP_URL; + if (!slug) return WEBAPP_URL.replace("https://", "").replace("http://", ""); const orgFullOrigin = `${ options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : "" }${slug}.${subdomainSuffix()}`; diff --git a/packages/features/ee/organizations/pages/components/MemberListItem.tsx b/packages/features/ee/organizations/pages/components/MemberListItem.tsx index 33586140d8..34e7190c3b 100644 --- a/packages/features/ee/organizations/pages/components/MemberListItem.tsx +++ b/packages/features/ee/organizations/pages/components/MemberListItem.tsx @@ -1,11 +1,9 @@ import classNames from "classnames"; import TeamPill, { TeamRole } from "@calcom/ee/teams/components/TeamPill"; -import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import { - Avatar, Button, ButtonGroup, Dropdown, @@ -17,6 +15,8 @@ import { } from "@calcom/ui"; import { ExternalLink, MoreHorizontal } from "@calcom/ui/components/icon"; +import { UserAvatar } from "@components/ui/avatar/UserAvatar"; + interface Props { member: RouterOutputs["viewer"]["organizations"]["listOtherTeamMembers"]["rows"][number]; } @@ -26,22 +26,17 @@ export default function MemberListItem(props: Props) { const { member } = props; const { user } = member; - const bookerUrl = useBookerUrl(); + const name = user.name || user.username || user.email; + const bookerUrl = props.member.bookerUrl; const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); const bookingLink = user.username && `${bookerUrlWithoutProtocol}/${user.username}`; - const name = user.name || user.username || user.email; return (
  • - +
    diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index da444655da..da793c4c65 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -6,24 +6,16 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal"; import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal"; import { classNames } from "@calcom/lib"; -import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; -import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; +import { APP_NAME } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; -import { - Avatar, - Badge, - Button, - showToast, - SkeletonButton, - SkeletonContainer, - SkeletonText, -} from "@calcom/ui"; +import { Badge, Button, showToast, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui"; import { ArrowRight, Plus, Trash2 } from "@calcom/ui/components/icon"; +import { UserAvatar } from "@calcom/web/components/ui/avatar/UserAvatar"; type TeamMember = RouterOutputs["viewer"]["teams"]["get"]["members"][number]; @@ -219,7 +211,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n const { t } = useLocale(); const utils = trpc.useContext(); const session = useSession(); - const bookerUrl = useBookerUrl(); + const bookerUrl = member.bookerUrl; const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, { enabled: !!session.data?.user?.org, }); @@ -247,7 +239,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n )} data-testid="pending-member-item">
    - +

    {member.name || member.email || t("team_member")}

    @@ -258,7 +250,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n {member.role === "ADMIN" && {t("admin")}}
    {member.username ? ( -

    {`${WEBAPP_URL}/${member.username}`}

    +

    {`${bookerUrl}/${member.username}`}

    ) : (

    {t("not_on_cal", { appName: APP_NAME })}

    )} diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index 632b4660d3..2dc3bc1021 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -3,7 +3,6 @@ import { SendIcon } from "lucide-react"; import { signIn } from "next-auth/react"; import { useState } from "react"; -import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -120,7 +119,7 @@ export default function MemberListItem(props: Props) { process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true"; const resendInvitation = editMode && !props.member.accepted; - const bookerUrl = useBookerUrl(); + const bookerUrl = props.member.bookerUrl; const bookerUrlWithoutProtocol = bookerUrl.replace(/^https?:\/\//, ""); const bookingLink = !!props.member.username && `${bookerUrlWithoutProtocol}/${props.member.username}`; const isAdmin = props.team && ["ADMIN", "OWNER"].includes(props.team.membership?.role); diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index 047695419f..4355ad6461 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -6,6 +6,7 @@ import InviteLinkSettingsModal from "@calcom/ee/teams/components/InviteLinkSetti import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal"; import classNames from "@calcom/lib/classNames"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { getTeamUrlSync } from "@calcom/lib/getBookerUrl/client"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -106,11 +107,9 @@ export default function TeamListItem(props: Props) { {team.name} {team.slug ? ( - orgBranding ? ( - `${orgBranding.fullDomain}/${team.slug}` - ) : ( - `${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}` - ) + `${getTeamUrlSync({ orgSlug: team.parent ? team.parent.slug : null, teamSlug: team.slug })}/${ + team.slug + }` ) : ( {t("upgrade")} )} diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 88c53e991f..8055ebb8e3 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -7,11 +7,10 @@ import { useLayoutEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { getTeamUrlSync } from "@calcom/lib/getBookerUrl/client"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { md } from "@calcom/lib/markdownIt"; @@ -269,7 +268,6 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => { }); const [firstRender, setFirstRender] = useState(true); - const orgBranding = useOrgBranding(); const { formState: { isSubmitting, isDirty }, @@ -375,11 +373,14 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => { name="slug" label={t("team_url")} value={value} - addOnLeading={ - team.parent && orgBranding - ? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/` - : `${WEBAPP_URL}/team/` - } + data-testid="team-url" + addOnClassname="testid-leading-text-team-url" + addOnLeading={`${getTeamUrlSync( + { orgSlug: team.parent ? team.parent.slug : null, teamSlug: null }, + { + protocol: false, + } + )}`} onChange={(e) => { form.clearErrors("slug"); form.setValue("slug", slugify(e?.target.value, true), { shouldDirty: true }); diff --git a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx index 8a6218d197..e0d346a6e5 100644 --- a/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx +++ b/packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx @@ -1,8 +1,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { Props } from "react-select"; -import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; -import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { classNames } from "@calcom/lib"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -15,6 +13,7 @@ export type ChildrenEventType = { label: string; created: boolean; owner: { + avatar: string; id: number; email: string; name: string; @@ -36,7 +35,6 @@ export const ChildrenEventTypeSelect = ({ onChange: (value: readonly ChildrenEventType[]) => void; }) => { const { t } = useLocale(); - const orgBranding = useOrgBranding(); const [animationRef] = useAutoAnimate(); return ( @@ -63,9 +61,7 @@ export const ChildrenEventTypeSelect = ({
    diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts index f1d11aba42..3ef573c63f 100644 --- a/packages/lib/getAvatarUrl.ts +++ b/packages/lib/getAvatarUrl.ts @@ -19,8 +19,22 @@ export const getUserAvatarUrl = ( }`; }; +export function getTeamAvatarUrl( + team: Pick & { + organizationId?: number | null; + logoUrl?: string | null; + requestedSlug: string | null; + } +) { + if (team.logoUrl) { + return team.logoUrl; + } + const slug = team.slug ?? team.requestedSlug; + return `${WEBAPP_URL}/team/${slug}/avatar.png${team.organizationId ? `?orgId=${team.organizationId}` : ""}`; +} + export const getOrgAvatarUrl = ( - org: Pick & { + org: Pick & { logoUrl?: string | null; requestedSlug: string | null; } diff --git a/packages/lib/getBookerUrl/client.ts b/packages/lib/getBookerUrl/client.ts new file mode 100644 index 0000000000..6eaa692764 --- /dev/null +++ b/packages/lib/getBookerUrl/client.ts @@ -0,0 +1,30 @@ +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; + +export const getBookerBaseUrlSync = ( + orgSlug: string | null, + options?: { + protocol: boolean; + } +) => { + return getOrgFullOrigin(orgSlug ?? "", options); +}; + +export const getTeamUrlSync = ( + { + orgSlug, + teamSlug, + }: { + orgSlug: string | null; + teamSlug: string | null; + }, + options?: { + protocol: boolean; + } +) => { + const bookerUrl = getBookerBaseUrlSync(orgSlug, options); + teamSlug = teamSlug || ""; + if (orgSlug) { + return `${bookerUrl}/${teamSlug}`; + } + return `${bookerUrl}/team/${teamSlug}`; +}; diff --git a/packages/lib/getBookerUrl/server.ts b/packages/lib/getBookerUrl/server.ts new file mode 100644 index 0000000000..f34c208943 --- /dev/null +++ b/packages/lib/getBookerUrl/server.ts @@ -0,0 +1,12 @@ +import { WEBAPP_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; +}; + +export const getTeamBookerUrl = async (team: { organizationId: number | null }) => { + const orgBrand = await getBrand(team.organizationId); + return orgBrand?.fullDomain ?? WEBAPP_URL; +}; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 9606d35108..b95cfd692c 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -15,6 +15,9 @@ import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-u import { TRPCError } from "@trpc/server"; +import { WEBAPP_URL } from "./constants"; +import { getBookerBaseUrl } from "./getBookerUrl/server"; + interface getEventTypeByIdProps { eventTypeId: number; userId: number; @@ -106,6 +109,11 @@ export default async function getEventTypeById({ successRedirectUrl: true, currency: true, bookingFields: true, + owner: { + select: { + organizationId: true, + }, + }, parent: { select: { teamId: true, @@ -167,6 +175,7 @@ export default async function getEventTypeById({ username: true, email: true, id: true, + organizationId: true, }, }, hidden: true, @@ -257,12 +266,18 @@ export default async function getEventTypeById({ metadata: parsedMetaData, customInputs: parsedCustomInputs, users: rawEventType.users, + bookerUrl: restEventType.team + ? await getBookerBaseUrl({ organizationId: restEventType.team.parentId }) + : restEventType.owner + ? await getBookerBaseUrl(restEventType.owner) + : WEBAPP_URL, children: restEventType.children.flatMap((ch) => ch.owner !== null ? { ...ch, owner: { ...ch.owner, + avatar: getUserAvatarUrl(ch.owner), email: ch.owner.email, name: ch.owner.name ?? "", username: ch.owner.username ?? "", diff --git a/packages/lib/server/getBookerUrl.ts b/packages/lib/server/getBookerUrl.ts deleted file mode 100644 index 9b42c92a30..0000000000 --- a/packages/lib/server/getBookerUrl.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { WEBAPP_URL } from "../constants"; -import { getBrand } from "./getBrand"; - -export const getBookerUrl = async (user: { organizationId: number | null }) => { - const orgBrand = await getBrand(user.organizationId); - return orgBrand?.fullDomain ?? WEBAPP_URL; -}; diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index a5d8a1b124..c910c8cea1 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -1,12 +1,12 @@ import { Prisma } from "@prisma/client"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import prisma, { baseEventTypeSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { WEBAPP_URL } from "../../../constants"; +import { getBookerBaseUrlSync } from "../../../getBookerUrl/client"; import { getTeam, getOrg } from "../../repository/team"; export type TeamWithMembers = Awaited>; @@ -158,7 +158,7 @@ export async function getTeamWithMembers(args: { .map((membership) => membership.team.slug) : null, avatar: `${WEBAPP_URL}/${m.user.username}/avatar.png`, - orgOrigin: getOrgFullOrigin(m.user.organization?.slug || ""), + bookerUrl: getBookerBaseUrlSync(m.user.organization?.slug || ""), connectedApps: !isTeamView ? credentials?.map((cred) => { const appSlug = cred.app?.slug; diff --git a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts index 41f4b33072..4dd6a0ef2f 100644 --- a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts @@ -1,6 +1,7 @@ import { isOrganization, withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils"; -import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; +import { getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { PrismaClient } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; @@ -36,6 +37,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti name: true, slug: true, metadata: true, + parentId: true, members: { select: { userId: true, @@ -51,24 +53,34 @@ export const teamsAndUserProfilesQuery = async ({ ctx }: TeamsAndUserProfileOpti if (!user) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const bookerUrl = await getBookerUrl(user); - const image = user?.username ? `${bookerUrl}/${user.username}/avatar.png` : undefined; - const nonOrgTeams = user.teams.filter((membership) => !isOrganization({ team: membership.team })); + const nonOrgTeams = user.teams + .filter((membership) => !isOrganization({ team: membership.team })) + .map((membership) => ({ + ...membership, + team: { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }, + })); return [ { teamId: null, name: user.name, slug: user.username, - image, + image: getUserAvatarUrl(user), readOnly: false, }, ...nonOrgTeams.map((membership) => ({ teamId: membership.team.id, name: membership.team.name, slug: membership.team.slug ? `team/${membership.team.slug}` : null, - image: `${bookerUrl}${membership.team.slug ? "/team" : ""}/${membership.team.slug}/avatar.png`, + image: getTeamAvatarUrl({ + slug: membership.team.slug, + requestedSlug: membership.team.metadata?.requestedSlug ?? null, + organizationId: membership.team.parentId, + }), role: membership.role, readOnly: !withRoleCanCreateEntity(membership.role), })), diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index d93ff40e91..eb1dbb1a13 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -15,11 +15,11 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { cancelScheduledJobs } from "@calcom/features/webhooks/lib/scheduleTrigger"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined } from "@calcom/lib"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server"; -import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; import { prisma } from "@calcom/prisma"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; @@ -53,7 +53,15 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule startTime: true, endTime: true, eventTypeId: true, - eventType: true, + eventType: { + include: { + team: { + select: { + parentId: true, + }, + }, + }, + }, location: true, attendees: true, references: true, @@ -175,9 +183,12 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule const [userAsPeopleType] = usersToPeopleType([user], userTranslation); const builder = new CalendarEventBuilder(); + const eventType = bookingToReschedule.eventType; builder.init({ title: bookingToReschedule.title, - bookerUrl: await getBookerUrl(user), + bookerUrl: eventType?.team + ? await getBookerBaseUrl({ organizationId: eventType.team.parentId }) + : await getBookerBaseUrl(user), type: event && event.slug ? event.slug : bookingToReschedule.title, startTime: bookingToReschedule.startTime.toISOString(), endTime: bookingToReschedule.endTime.toISOString(), diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts index 83f8089c88..9ce7b0ae0f 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.handler.ts @@ -4,9 +4,10 @@ import { orderBy } from "lodash"; import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { CAL_URL } from "@calcom/lib/constants"; +import { getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; import type { PrismaClient } from "@calcom/prisma"; import { baseEventTypeSelect } from "@calcom/prisma"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; @@ -116,6 +117,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => slug: true, parentId: true, metadata: true, + parent: true, members: { select: { userId: true, @@ -160,6 +162,14 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } + const memberships = user.teams.map((membership) => ({ + ...membership, + team: { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }, + })); + type UserEventTypes = (typeof user.eventTypes)[number]; type TeamEventTypeChildren = (typeof user.teams)[number]["team"]["eventTypes"][number]; @@ -176,11 +186,12 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => type EventTypeGroup = { teamId?: number | null; parentId?: number | null; + bookerUrl: string; membershipRole?: MembershipRole | null; profile: { slug: (typeof user)["username"]; name: (typeof user)["name"]; - image?: string; + image: string; }; metadata: { membershipCount: number; @@ -195,16 +206,16 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => (evType) => evType.schedulingType !== SchedulingType.MANAGED ); - const image = user?.username ? `${CAL_URL}/${user.username}/avatar.png` : undefined; - if (!input?.filters || !hasFilter(input?.filters) || input?.filters?.userIds?.includes(user.id)) { + const bookerUrl = await getBookerBaseUrl(user); eventTypeGroups.push({ teamId: null, + bookerUrl, membershipRole: null, profile: { slug: user.username, name: user.name, - image, + image: getUserAvatarUrl({ username: user.username, organizationId: user.organizationId }), }, eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]), metadata: { @@ -227,9 +238,9 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }; eventTypeGroups = ([] as EventTypeGroup[]).concat( eventTypeGroups, - user.teams + memberships .filter((mmship) => { - const metadata = teamMetadataSchema.parse(mmship.team.metadata); + const metadata = mmship.team.metadata; if (metadata?.isOrganization) { return false; } else { @@ -243,33 +254,50 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => const orgMembership = teamMemberships.find( (teamM) => teamM.teamId === membership.team.parentId )?.membershipRole; + + const team = { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }; + + let slug; + + if (input?.forRoutingForms) { + // For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team + // Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug + slug = `team/${team.slug}`; + } else { + // In an Org, a team can be accessed without /team prefix as well as with /team prefix + slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null; + } return { - teamId: membership.team.id, - parentId: membership.team.parentId, + teamId: team.id, + parentId: team.parentId, + bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null), membershipRole: orgMembership && compareMembership(orgMembership, membership.role) ? orgMembership : membership.role, profile: { - name: membership.team.name, - image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`, - slug: membership.team.slug - ? !membership.team.parentId - ? `team/${membership.team.slug}` - : `${membership.team.slug}` - : null, + image: getTeamAvatarUrl({ + slug: team.slug, + requestedSlug: team.metadata?.requestedSlug ?? null, + organizationId: team.parentId, + }), + name: team.name, + slug, }, metadata: { - membershipCount: membership.team.members.length, + membershipCount: team.members.length, readOnly: membership.role === - (membership.team.parentId + (team.parentId ? orgMembership && compareMembership(orgMembership, membership.role) ? orgMembership : MembershipRole.MEMBER : MembershipRole.MEMBER), }, - eventTypes: membership.team.eventTypes + eventTypes: team.eventTypes .map(mapEventType) .filter(filterTeamsEventTypesBasedOnInput) .filter((evType) => evType.userId === null || evType.userId === ctx.user.id) @@ -282,7 +310,6 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => }) ); - const bookerUrl = await getBookerUrl(user); return { eventTypeGroups, // so we can show a dropdown when the user has teams @@ -291,7 +318,6 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => ...group.metadata, teamId: group.teamId, membershipRole: group.membershipRole, - image: `${bookerUrl}/${group.profile.slug}/avatar.png`, })), }; }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts index 1d3dd6f41b..b7a7424552 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getByViewer.schema.ts @@ -9,6 +9,7 @@ export const filterQuerySchemaStrict = z.object({ export const ZEventTypeInputSchema = z .object({ filters: filterQuerySchemaStrict.optional(), + forRoutingForms: z.boolean().optional(), }) .nullish(); diff --git a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts index d9634a908f..0aad9bdc39 100644 --- a/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listOtherTeamMembers.handler.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import z from "zod"; +import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "../../../trpc"; @@ -70,6 +71,8 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => { name: true, email: true, avatar: true, + organization: true, + organizationId: true, }, }, }, @@ -85,7 +88,12 @@ export const listOtherTeamMembers = async ({ input }: ListOptions) => { } return { - rows: members || [], + rows: members.map((m) => { + return { + ...m, + bookerUrl: getBookerBaseUrlSync(m.user.organization?.slug || ""), + }; + }), nextCursor, }; }; diff --git a/packages/trpc/server/routers/viewer/teams/get.handler.ts b/packages/trpc/server/routers/viewer/teams/get.handler.ts index ca6256d448..bed27cda9b 100644 --- a/packages/trpc/server/routers/viewer/teams/get.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/get.handler.ts @@ -1,6 +1,5 @@ import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; -import type { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -27,12 +26,16 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { const membership = team?.members.find((membership) => membership.id === ctx.user.id); + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "Not a member of this team." }); + } + return { ...team, safeBio: markdownToSafeHTML(team.bio), membership: { - role: membership?.role as MembershipRole, - accepted: membership?.accepted, + role: membership.role, + accepted: membership.accepted, }, }; }; diff --git a/packages/trpc/server/routers/viewer/teams/list.handler.ts b/packages/trpc/server/routers/viewer/teams/list.handler.ts index 31a53fdd48..23a29411ff 100644 --- a/packages/trpc/server/routers/viewer/teams/list.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/list.handler.ts @@ -22,6 +22,7 @@ export const listHandler = async ({ ctx }: ListOptions) => { team: { include: { inviteTokens: true, + parent: true, }, }, }, diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts index f15988b424..e0a60bd8b9 100644 --- a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts @@ -1,4 +1,4 @@ -import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; +import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import { prisma } from "@calcom/prisma"; import type { Webhook } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -92,7 +92,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => { let userWebhooks = user.webhooks; userWebhooks = userWebhooks.filter(filterWebhooks); let webhookGroups: WebhookGroup[] = []; - const bookerUrl = await getBookerUrl(user); + const bookerUrl = await getBookerBaseUrl(user); const image = user?.username ? `${bookerUrl}/${user.username}/avatar.png` : undefined; webhookGroups.push({ diff --git a/packages/ui/components/createButton/CreateButton.tsx b/packages/ui/components/createButton/CreateButton.tsx index 51b4a3c973..eb4818983a 100644 --- a/packages/ui/components/createButton/CreateButton.tsx +++ b/packages/ui/components/createButton/CreateButton.tsx @@ -1,6 +1,5 @@ import { usePathname, useRouter } from "next/navigation"; -import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { ButtonColor } from "@calcom/ui"; @@ -19,7 +18,7 @@ import { Plus } from "@calcom/ui/components/icon"; export interface Option { teamId: number | null | undefined; // if undefined, then it's a profile label: string | null; - image?: string | null; + image: string | null; slug: string | null; } @@ -43,7 +42,6 @@ export function CreateButton(props: CreateBtnProps) { const router = useRouter(); const searchParams = useCompatSearchParams(); const pathname = usePathname(); - const bookerUrl = useBookerUrl(); const { createDialog, @@ -114,12 +112,7 @@ export function CreateButton(props: CreateBtnProps) { type="button" data-testid={`option${option.teamId ? "-team" : ""}-${idx}`} StartIcon={(props) => ( - + )} onClick={() => !!CreateDialog