From f86c4483590a04891f1ce1b82594f312f7e4a15c Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Wed, 10 Apr 2024 21:27:04 +0530 Subject: [PATCH] fix: view team bookings (#14079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: view team bookings * fix: edge case- common user across 2 different teams * @SomayChauhan fix: edge case- common user across 2 different teams v2.0 * feat: added booking list test for collective event-types * feat: added booking list test for round-robin event-types * feat: added test for managed event types bookings * feat: added test for individual events bookings * refactor code * added comments * added isFixed to collective event hosts * better function naming * code refactor * fix: assertBookingVisibleFor ignoring today's meetings * fix: handling case for organizations * fix: non org bookings-list test * feat: added tests for organisations booking list * added comments for better clarity * booking-list in org tests * uncommented doOnOrgDomain * removed extra selectors * fix: failing tests * fix: db-load on bookings.get * fix: only select the required fields * followUp: Improve getUniqueBookings perf --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Omar López --- apps/web/playwright/bookings-list.e2e.ts | 209 ++++++++++ apps/web/playwright/fixtures/users.ts | 2 +- apps/web/playwright/lib/testUtils.ts | 51 +++ .../organization/booking-list.e2e.ts | 391 ++++++++++++++++++ .../routers/viewer/bookings/get.handler.ts | 243 +++++++---- 5 files changed, 821 insertions(+), 75 deletions(-) create mode 100644 apps/web/playwright/organization/booking-list.e2e.ts diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index de5ccb55a3..52404b8718 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -1,9 +1,15 @@ import { expect } from "@playwright/test"; +import { randomString } from "@calcom/lib/random"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { createTeamEventType } from "./fixtures/users"; import type { Fixtures } from "./lib/fixtures"; import { test } from "./lib/fixtures"; +import { assertBookingVisibleFor, bookTeamEvent, bookUserEvent } from "./lib/testUtils"; test.afterEach(({ users }) => users.deleteAll()); @@ -61,6 +67,137 @@ test.describe("Bookings", () => { secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`) ).toBeVisible(); }); + test("collective eventType booking should be visible to team admin", async ({ page, users }) => { + const { owner1, owner2, commonUser, team1_teammate1 } = await createTeams(users); + + const { team: team1 } = await owner1.getFirstTeamMembership(); + const scenario = { + schedulingType: SchedulingType.COLLECTIVE, + teamEventTitle: `collective-team-event`, + teamEventSlug: slugify(`collective-team-event-${randomString(5)}`), + }; + + const eventType = await createTeamEventType(owner1, team1, scenario); + const { id: eventId, slug: evnetSlug } = eventType; + + await prisma.host.createMany({ + data: [ + { + userId: commonUser.id, + eventTypeId: eventId, + isFixed: true, + }, + { + userId: team1_teammate1.id, + eventTypeId: eventId, + isFixed: true, + }, + ], + }); + + // to test if booking is visible to team admin/owner even if he is not part of the booking + await prisma.host.deleteMany({ + where: { + userId: owner1.id, + eventTypeId: eventId, + }, + }); + + await bookTeamEvent(page, team1, eventType); + + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(owner1, page, eventType, true); + // booking should not be visible for other team host + await assertBookingVisibleFor(owner2, page, eventType, false); + // booking should be visible for commonUser as he is part of the booking + await assertBookingVisibleFor(commonUser, page, eventType, true); + // booking should be visible for team1_teammate1 as he is part of the booking + await assertBookingVisibleFor(team1_teammate1, page, eventType, true); + }); + test("round-robin eventType booking should be visible to team admin", async ({ page, users }) => { + const { owner1, owner2, commonUser, team1_teammate1 } = await createTeams(users); + + const { team: team1 } = await owner1.getFirstTeamMembership(); + const scenario = { + schedulingType: SchedulingType.ROUND_ROBIN, + teamEventTitle: `round-robin-team-event`, + teamEventSlug: slugify(`round-robin-team-event-${randomString(5)}`), + }; + + const eventType = await createTeamEventType(owner1, team1, scenario); + const { id: eventId } = eventType; + + await prisma.host.createMany({ + data: [ + { + userId: commonUser.id, + eventTypeId: eventId, + }, + { + userId: team1_teammate1.id, + eventTypeId: eventId, + }, + ], + }); + + await prisma.host.deleteMany({ + where: { + userId: owner1.id, + eventTypeId: eventId, + }, + }); + + await bookTeamEvent(page, team1, eventType); + + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(owner1, page, eventType, true); + // bookings should not be visible for other team host + await assertBookingVisibleFor(owner2, page, eventType, false); + }); + test("managed eventType booking should be visible to team admin", async ({ page, users }) => { + const { owner1, owner2, commonUser, team1_teammate1 } = await createTeams(users); + + const { team: team1 } = await owner1.getFirstTeamMembership(); + + await owner1.apiLogin(); + await page.goto(`/event-types?dialog=new&eventPage=team%2F${team1.slug}&teamId=${team1.id}`); + await page.getByTestId("managed-event-type").click(); + await page.getByTestId("event-type-quick-chat").click(); + await page.getByTestId("event-type-quick-chat").fill("managed-event"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByTestId("vertical-tab-assignment").click(); + + await page.getByRole("combobox", { name: "assignment-dropdown" }).fill("commonUser"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).press("Enter"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).fill("team1_teammate1"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).press("Enter"); + await page.getByTestId("update-eventtype").click(); + + const eventType = await owner1.getFirstTeamEvent(team1.id); + + await bookUserEvent(page, commonUser, eventType); + + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(owner1, page, eventType, true); + // booking should not be visible for other team host + await assertBookingVisibleFor(owner2, page, eventType, false); + // booking should be visible for commonUser as he is part of the booking + await assertBookingVisibleFor(commonUser, page, eventType, true); + // booking should not be visible for team1_teammate1 as we booked for commonUser + await assertBookingVisibleFor(team1_teammate1, page, eventType, false); + }); + test("individual team members booking should not be visible to team admin", async ({ page, users }) => { + const { owner1, owner2, commonUser, team1_teammate1 } = await createTeams(users); + + const eventType = await commonUser.getFirstEventAsOwner(); + await bookUserEvent(page, commonUser, eventType); + // non-team bookings should not be visible for the team host + await assertBookingVisibleFor(owner1, page, eventType, false); + // non-team bookings should not be visible for other team host + await assertBookingVisibleFor(owner2, page, eventType, false); + // booking should be visible for commonUser as he is part of the booking + await assertBookingVisibleFor(commonUser, page, eventType, true); + }); }); }); @@ -111,3 +248,75 @@ async function createBooking({ }, }); } + +const createTeams = async (userFixture: Fixtures["users"]) => { + const owner1 = await userFixture.create({ name: "team-owner-1" }); + const owner2 = await userFixture.create({ name: "team-owner-2" }); + const commonUser = await userFixture.create({ name: "commonUser" }); + const team1_teammate1 = await userFixture.create({ name: "team1_teammate1" }); + const team2_teammate1 = await userFixture.create({ name: "team2_teammate1" }); + const teamOne = await prisma.team.create({ + data: { + name: "bookings-test-team-1", + slug: slugify(`bookings-test-team-2-${randomString(5)}`), + }, + }); + + const teamTwo = await prisma.team.create({ + data: { + name: "bookings-test-team-2", + slug: slugify(`bookings-test-team-2-${randomString(5)}`), + }, + }); + + // create memberships + await prisma.membership.createMany({ + data: [ + { + userId: owner1.id, + teamId: teamOne.id, + accepted: true, + role: "OWNER", + }, + { + userId: commonUser.id, + teamId: teamOne.id, + accepted: true, + role: "MEMBER", + }, + { + userId: team1_teammate1.id, + teamId: teamOne.id, + accepted: true, + role: "MEMBER", + }, + { + userId: owner2.id, + teamId: teamTwo.id, + accepted: true, + role: "OWNER", + }, + { + userId: commonUser.id, + teamId: teamTwo.id, + accepted: true, + role: "MEMBER", + }, + { + userId: team2_teammate1.id, + teamId: teamTwo.id, + accepted: true, + role: "MEMBER", + }, + ], + }); + return { + owner1, + owner2, + commonUser, + team1_teammate1, + team2_teammate1, + teamOne, + teamTwo, + }; +}; diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index e9b368c58f..4e7badbaa3 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -58,7 +58,7 @@ const createTeamWorkflow = async (user: { id: number }, team: { id: number }) => }); }; -const createTeamEventType = async ( +export const createTeamEventType = async ( user: { id: number }, team: { id: number }, scenario?: { diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 01720d5947..1968ad66fa 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,4 +1,5 @@ import type { Frame, Page } from "@playwright/test"; +import type { APIResponse } from "@playwright/test"; import { expect } from "@playwright/test"; import { createHash } from "crypto"; import EventEmitter from "events"; @@ -371,3 +372,53 @@ export async function doOnOrgDomain( // When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory. export const NotFoundPageTextAppDir = "This page does not exist."; // export const NotFoundPageText = "ERROR 404"; + +export type Team = { + id: number; + slug: string | null; + name: string; +}; + +export type EventType = { + id: number; + title: string; + slug: string; +}; +export type User = { + id: number; + name: string | null; + username: string | null; +}; + +export async function bookTeamEvent(page: Page, team: Team, eventType: EventType) { + await page.goto(`/team/${team.slug}/${eventType.slug}/`); + await bookEventOnThisPage(page); + const bookingTitle = `${eventType.title} between ${team.name} and ${testName}`; + await assertBookingIsCorrect(page, bookingTitle); +} +export async function bookUserEvent(page: Page, user: User, eventType: EventType) { + await page.goto(`/${user.username}/${eventType.slug}/`); + await bookEventOnThisPage(page); + const bookingTitle = `${eventType.title} between ${user.name} and ${testName}`; + await assertBookingIsCorrect(page, bookingTitle); +} + +export async function assertBookingIsCorrect(page: Page, bookingTitle: string) { + await expect(page.locator("[data-testid=booking-title]")).toHaveText(bookingTitle); + // The booker should be in the attendee list + await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); +} + +export const assertBookingVisibleFor = async ( + user: { apiLogin: () => Promise }, + pageFixture: Fixtures["page"], + eventType: EventType, + shouldBeVisible: boolean +) => { + await user.apiLogin(); + await pageFixture.goto(`/bookings/upcoming`); + const upcomingBookingList = pageFixture.locator('[data-testid="booking-item"]'); + shouldBeVisible + ? await expect(upcomingBookingList.locator(`text=${eventType.title}`)).toBeVisible() + : await expect(upcomingBookingList.locator(`text=${eventType.title}`)).toBeHidden(); +}; diff --git a/apps/web/playwright/organization/booking-list.e2e.ts b/apps/web/playwright/organization/booking-list.e2e.ts new file mode 100644 index 0000000000..05cefbe0a3 --- /dev/null +++ b/apps/web/playwright/organization/booking-list.e2e.ts @@ -0,0 +1,391 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { randomString } from "@calcom/lib/random"; +import slugify from "@calcom/lib/slugify"; +import prisma from "@calcom/prisma"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; + +import { createTeamEventType } from "../fixtures/users"; +import { test } from "../lib/fixtures"; +import type { Fixtures } from "../lib/fixtures"; +import { + assertBookingVisibleFor, + bookTimeSlot, + doOnOrgDomain, + NotFoundPageTextAppDir, + selectFirstAvailableTimeSlotNextMonth, + testName, +} from "../lib/testUtils"; + +test.describe("Bookings list for organizations", () => { + test.afterEach(({ orgs, users }) => { + orgs.deleteAll(); + users.deleteAll(); + }); + + test.describe("Team Event", () => { + test("Collective EventType booking list", async ({ page, users, orgs }) => { + const { org, orgOwner, orgAdmin, team1owner, team2owner, commonUser, team1_teammate1 } = + await createOrg(orgs, users); + + const { team: team1 } = await team1owner.getFirstTeamMembership(); + const scenario = { + schedulingType: SchedulingType.COLLECTIVE, + teamEventTitle: `collective-team-event`, + teamEventSlug: slugify(`collective-team-event-${randomString(5)}`), + }; + const eventType = await createTeamEventType(team1owner, team1, scenario); + const { id: eventId, slug: evnetSlug } = eventType; + + await prisma.host.createMany({ + data: [ + { + userId: commonUser.id, + eventTypeId: eventId, + isFixed: true, + }, + { + userId: team1_teammate1.id, + eventTypeId: eventId, + isFixed: true, + }, + ], + }); + + // to test if booking is visible to team admin/owner even if he is not part of the booking + await prisma.host.deleteMany({ + where: { + userId: team1owner.id, + eventTypeId: eventId, + }, + }); + + await expectPageToBeNotFound({ page, url: `/team/${team1.slug}/${eventType.slug}` }); + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookTeamEvent({ page, team: team1, event: eventType }); + } + ); + // booking should be visible for the Org OWNER even though he is not part of the booking + await assertBookingVisibleFor(orgOwner, page, eventType, true); + // booking should be visible for the Org ADMIN even though he is not part of the booking + await assertBookingVisibleFor(orgAdmin, page, eventType, true); + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(team1owner, page, eventType, true); + // booking should not be visible for other team host, because its a team1 team-event + await assertBookingVisibleFor(team2owner, page, eventType, false); + // booking should be visible for commonUser as he is part of the booking + await assertBookingVisibleFor(commonUser, page, eventType, true); + // booking should be visible for team1_teammate1 as he is part of the booking + await assertBookingVisibleFor(team1_teammate1, page, eventType, true); + }); + + test("Round Robin EventType booking list", async ({ page, users, orgs }) => { + const { org, orgOwner, orgAdmin, team1owner, team2owner, commonUser, team1_teammate1 } = + await createOrg(orgs, users); + + const { team: team1 } = await team1owner.getFirstTeamMembership(); + const scenario = { + schedulingType: SchedulingType.ROUND_ROBIN, + teamEventTitle: `round-robin-team-event`, + teamEventSlug: slugify(`round-robin-team-event-${randomString(5)}`), + }; + + const eventType = await createTeamEventType(team1owner, team1, scenario); + const { id: eventId } = eventType; + await prisma.host.createMany({ + data: [ + { + userId: commonUser.id, + eventTypeId: eventId, + }, + { + userId: team1_teammate1.id, + eventTypeId: eventId, + }, + ], + }); + + await prisma.host.deleteMany({ + where: { + userId: team1owner.id, + eventTypeId: eventId, + }, + }); + + await expectPageToBeNotFound({ page, url: `/team/${team1.slug}/${eventType.slug}` }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookTeamEvent({ page, team: team1, event: eventType }); + } + ); + // booking should be visible for the Org OWNER even though he is not part of the booking + await assertBookingVisibleFor(orgOwner, page, eventType, true); + // booking should be visible for the Org ADMIN even though he is not part of the booking + await assertBookingVisibleFor(orgAdmin, page, eventType, true); + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(team1owner, page, eventType, true); + // booking should not be visible for other team host + await assertBookingVisibleFor(team2owner, page, eventType, false); + }); + + test("Managed EventType booking list", async ({ page, users, orgs }) => { + const { org, orgOwner, orgAdmin, team1owner, team2owner, commonUser, team1_teammate1 } = + await createOrg(orgs, users); + + const { team: team1 } = await team1owner.getFirstTeamMembership(); + + await team1owner.apiLogin(); + await page.goto(`/event-types?dialog=new&eventPage=team%2F${team1.slug}&teamId=${team1.id}`); + await page.getByTestId("managed-event-type").click(); + await page.getByTestId("event-type-quick-chat").click(); + await page.getByTestId("event-type-quick-chat").fill("managed-event"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByTestId("vertical-tab-assignment").click(); + + await page.getByRole("combobox", { name: "assignment-dropdown" }).fill("commonUser"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).press("Enter"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).fill("team1_teammate1"); + await page.getByRole("combobox", { name: "assignment-dropdown" }).press("Enter"); + await page.getByTestId("update-eventtype").click(); + + const eventType = await team1owner.getFirstTeamEvent(team1.id); + + await expectPageToBeNotFound({ page, url: `/${commonUser.username}/${eventType.slug}` }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookUserEvent({ page, user: commonUser, event: eventType }); + } + ); + + // booking should be visible for the Org OWNER even though he is not part of the booking + await assertBookingVisibleFor(orgOwner, page, eventType, true); + // booking should be visible for the Org ADMIN even though he is not part of the booking + await assertBookingVisibleFor(orgAdmin, page, eventType, true); + // booking should be visible for the team host even though he is not part of the booking + await assertBookingVisibleFor(team1owner, page, eventType, true); + // booking should not be visible for other team host + await assertBookingVisibleFor(team2owner, page, eventType, false); + }); + }); + + test.describe("User Event", () => { + test("User personal booking list", async ({ page, users, orgs }) => { + const { org, orgOwner, orgAdmin, team1owner, team2owner, commonUser, team1_teammate1 } = + await createOrg(orgs, users); + + const event = await commonUser.getFirstEventAsOwner(); + + await page.goto(`/${commonUser.username}/${event.slug}`); + // Shouldn't be servable on the non-org domain + await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible(); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookUserEvent({ page, user: commonUser, event }); + } + ); + // booking should be visible for the Org OWNER + await assertBookingVisibleFor(orgOwner, page, event, true); + // booking should be visible for the Org ADMIN + await assertBookingVisibleFor(orgAdmin, page, event, true); + // booking should be visible for the team host + await assertBookingVisibleFor(team1owner, page, event, true); + // booking should be visible for other team host because commonUser is part of both teams + await assertBookingVisibleFor(team2owner, page, event, true); + // booking should be visible for commonUser as he is part of the booking + await assertBookingVisibleFor(commonUser, page, event, true); + // booking should not be visible for team1_teammate1 + await assertBookingVisibleFor(team1_teammate1, page, event, false); + }); + }); +}); + +async function bookUserEvent({ + page, + user, + event, +}: { + page: Page; + user: { + username: string | null; + name: string | null; + }; + event: { slug: string; title: string }; +}) { + await page.goto(`/${user.username}/${event.slug}`); + + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + await expect(page.getByTestId("success-page")).toBeVisible(); + + // The title of the booking + const BookingTitle = `${event.title} between ${user.name} and ${testName}`; + await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); + // The booker should be in the attendee list + await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); +} + +async function bookTeamEvent({ + page, + team, + event, +}: { + page: Page; + team: { + slug: string | null; + name: string | null; + }; + event: { slug: string; title: string }; +}) { + // Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling + // So, we are using /team in the URL to access the team booking + // There are separate tests to verify that the next.config.js rewrites are working + // Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts + await page.goto(`/team/${team.slug}/${event.slug}`); + + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + await expect(page.getByTestId("success-page")).toBeVisible(); + + // The title of the booking + const BookingTitle = `${event.title} between ${team.name} and ${testName}`; + await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); + // The booker should be in the attendee list + await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); +} + +async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) { + await page.goto(`${url}`); + await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible(); +} + +const createOrg = async (orgs: Fixtures["orgs"], userFixture: Fixtures["users"]) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const orgOwner = await userFixture.create({ + name: "org-owner", + organizationId: org.id, + roleInOrganization: MembershipRole.OWNER, + }); + + const orgAdmin = await userFixture.create({ + name: "org-admin", + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + }); + const team1owner = await userFixture.create({ + name: "team-owner-1", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + const team2owner = await userFixture.create({ + name: "team-owner-2", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + const commonUser = await userFixture.create({ + name: "commonUser", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + const team1_teammate1 = await userFixture.create({ + name: "team1_teammate1", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + const team2_teammate1 = await userFixture.create({ + name: "team2_teammate1", + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + const teamOne = await prisma.team.create({ + data: { + name: "bookings-test-team-1", + slug: slugify(`bookings-test-team-2-${randomString(5)}`), + parentId: org.id, + }, + }); + + const teamTwo = await prisma.team.create({ + data: { + name: "bookings-test-team-2", + slug: slugify(`bookings-test-team-2-${randomString(5)}`), + parentId: org.id, + }, + }); + + // create memberships + await prisma.membership.createMany({ + data: [ + { + userId: team1owner.id, + teamId: teamOne.id, + accepted: true, + role: "OWNER", + }, + { + userId: commonUser.id, + teamId: teamOne.id, + accepted: true, + role: "MEMBER", + }, + { + userId: team1_teammate1.id, + teamId: teamOne.id, + accepted: true, + role: "MEMBER", + }, + { + userId: team2owner.id, + teamId: teamTwo.id, + accepted: true, + role: "OWNER", + }, + { + userId: commonUser.id, + teamId: teamTwo.id, + accepted: true, + role: "MEMBER", + }, + { + userId: team2_teammate1.id, + teamId: teamTwo.id, + accepted: true, + role: "MEMBER", + }, + ], + }); + return { + org, + orgOwner, + orgAdmin, + team1owner, + team2owner, + commonUser, + team1_teammate1, + team2_teammate1, + teamOne, + teamTwo, + }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 7851374e7e..b2e96c3b50 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -40,15 +40,12 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { }; }; -const set = new Set(); -const getUniqueBookings = (arr: T[]) => { - const unique = arr.filter((booking) => { - const duplicate = set.has(booking.uid); - set.add(booking.uid); - return !duplicate; +const getUniqueBookings = (arr: T[]) => { + const unique = new Set(); + arr.forEach((booking) => { + unique.add(booking.id); }); - set.clear(); - return unique; + return Array.from(unique.values()); }; export async function getBookings({ @@ -128,7 +125,6 @@ export async function getBookings({ ], }, }; - const filtersCombined: Prisma.BookingWhereInput[] = !filters ? [] : Object.keys(filters) @@ -196,60 +192,135 @@ export async function getBookings({ }, }; - const [ - // Quering these in parallel to save time. - // Note that because we are applying `take` to individual queries, we will usually get more bookings then we need. It is okay to have more bookings faster than having what we need slower - bookingsQueryUserId, - bookingsQueryAttendees, - bookingsQueryTeamMember, - bookingsQuerySeatReference, - ////////////////////////// - - recurringInfoBasic, - recurringInfoExtended, - // We need all promises to be successful, so we are not using Promise.allSettled - ] = await Promise.all([ - prisma.booking.findMany({ - where: { - OR: [ - { - userId: user.id, - }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], + const userBookingsFilter: Prisma.BookingWhereInput = { + userId: user.id, + }; + const userAttendingBookingsFilter: Prisma.BookingWhereInput = { + attendees: { + some: { + email: user.email, }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { - OR: [ - { - attendees: { - some: { - email: user.email, + }, + }; + // all (collective + round-robin) team bookings should be visible to team-owner and team-admins + const teamBookingsFilter: Prisma.BookingWhereInput = { + eventType: { + team: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }; + // all (collective + round-robin) team bookings should be visible to org-owner and org-admins + const organizationBookingsFilter: Prisma.BookingWhereInput = { + eventType: { + team: { + parent: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], }, }, }, - ], - AND: [passedBookingsStatusFilter, ...filtersCombined], + }, }, - orderBy, - take: take + 1, - skip, - }), - prisma.booking.findMany({ - where: { + }, + }; + + // all managed team bookings should be visible to team-owner and team-admins + const teamManagedBookingsFilter: Prisma.BookingWhereInput = { + eventType: { + parent: { + team: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + }; + + // all managed team bookings should be visible to org-owner and org-admins + const organizationManagedBookingsFilter: Prisma.BookingWhereInput = { + eventType: { + parent: { + team: { + parent: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + }, + }; + + // show personal booking to org-owner and org-admins + const organizationPersonalBookingsFilter: Prisma.BookingWhereInput = { + AND: [ + { + user: { + teams: { + some: { + team: { + parentId: { gt: 0 }, + }, + }, + }, + }, + }, + { OR: [ { - eventType: { - team: { - members: { - some: { - userId: user.id, - role: { - in: ["ADMIN", "OWNER"], + user: { + teams: { + some: { + team: { + parent: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + user: { + teams: { + some: { + team: { + members: { + some: { + userId: user.id, + role: { + in: ["ADMIN", "OWNER"], + }, + }, }, }, }, @@ -257,28 +328,55 @@ export async function getBookings({ }, }, ], - AND: [passedBookingsStatusFilter, ...filtersCombined], }, - orderBy, - take: take + 1, - skip, - }), + // filter out all (collective + round-robin) event bookings + { + eventType: { + teamId: null, + }, + }, + // filter out all (managed) event bookings + { + eventType: { + parentId: null, + }, + }, + ], + }; + + const seatsReferenceFilter: Prisma.BookingWhereInput = { + seatsReferences: { + some: { + attendee: { + email: user.email, + }, + }, + }, + }; + const [ + // Quering these in parallel to save time. + // Note that because we are applying `take` to individual queries, we will usually get more bookings then we need. It is okay to have more bookings faster than having what we need slower + bookingsQueryUserId, + recurringInfoBasic, + recurringInfoExtended, + // We need all promises to be successful, so we are not using Promise.allSettled + ] = await Promise.all([ prisma.booking.findMany({ where: { OR: [ - { - seatsReferences: { - some: { - attendee: { - email: user.email, - }, - }, - }, - }, + userBookingsFilter, + userAttendingBookingsFilter, + teamBookingsFilter, + organizationBookingsFilter, + teamManagedBookingsFilter, + organizationManagedBookingsFilter, + organizationPersonalBookingsFilter, + seatsReferenceFilter, ], AND: [passedBookingsStatusFilter, ...filtersCombined], }, orderBy, + select: { id: true }, take: take + 1, skip, }), @@ -345,9 +443,6 @@ export async function getBookings({ const plainBookings = getUniqueBookings( // It's going to mess up the orderBy as we are concatenating independent queries results bookingsQueryUserId - .concat(bookingsQueryAttendees) - .concat(bookingsQueryTeamMember) - .concat(bookingsQuerySeatReference) ); // Now enrich bookings with relation data. We could have queried the relation data along with the bookings, but that would cause unnecessary queries to the database. @@ -356,7 +451,7 @@ export async function getBookings({ await prisma.booking.findMany({ where: { id: { - in: plainBookings.map((booking) => booking.id), + in: plainBookings, }, }, select: bookingSelect,