From ba8cca55e89e1e4c11c6956fd642066fd0119bc5 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 26 Aug 2025 14:43:41 +0300 Subject: [PATCH] refactor: convert lucky user to service class (#23329) * refactor: lucky user to service class with DI * fix: unit test filterHostsByLeadThreshold.test.ts * fixup! fix: unit test filterHostsByLeadThreshold.test.ts * fix: get lucky user test missing mocks * fix: get lucky user hosts not within interval * chore: bump platform library * chore: bump platform library * chore: add DAILY_API_KEY to turborepo.json db seed * chore: add DAILY_API_KEY to cache-db action * fixup! chore: add DAILY_API_KEY to cache-db action * chore: remove log in fixture * chore: bump platform library * chore: refactor test team-emails.e2e * only failing test * run all api v2 tests * chore: bump platform library * revert change to turbo.json and action in cache-db * chore: use inPerson location in team-emails.e2e --- apps/api/v2/package.json | 2 +- .../e2e/emails/team-emails.e2e-spec.ts | 1178 +++------- .../prisma-attribute.repository.ts | 12 + .../repositories/prisma-host.repository.ts | 12 + .../v2/src/lib/services/lucky-user.service.ts | 27 + .../repository/api-keys.repository.fixture.ts | 1 - .../features/bookings/lib/handleNewBooking.ts | 5 +- .../ee/round-robin/roundRobinReassignment.ts | 6 +- .../filterHostsByLeadThreshold.test.ts | 27 +- .../bookings/filterHostsByLeadThreshold.ts | 11 +- packages/lib/di/containers/LuckyUser.ts | 25 + packages/lib/di/modules/Attribute.ts | 9 + packages/lib/di/modules/Host.ts | 7 + packages/lib/di/modules/LuckyUser.ts | 13 + packages/lib/di/tokens.ts | 6 + .../server/getLuckyUser.integration-test.ts | 26 +- packages/lib/server/getLuckyUser.test.ts | 201 +- packages/lib/server/getLuckyUser.ts | 1963 ++++++++--------- .../repository/PrismaAttributeRepository.ts | 41 + packages/lib/server/repository/host.ts | 23 + packages/lib/server/repository/ooo.ts | 153 +- packages/lib/server/repository/user.ts | 41 +- packages/platform/libraries/bookings.ts | 2 + packages/platform/libraries/repositories.ts | 2 + ...rsMatchingAttributeLogicOfRoute.handler.ts | 5 +- yarn.lock | 10 +- 26 files changed, 1806 insertions(+), 2002 deletions(-) create mode 100644 apps/api/v2/src/lib/repositories/prisma-attribute.repository.ts create mode 100644 apps/api/v2/src/lib/repositories/prisma-host.repository.ts create mode 100644 apps/api/v2/src/lib/services/lucky-user.service.ts create mode 100644 packages/lib/di/containers/LuckyUser.ts create mode 100644 packages/lib/di/modules/Attribute.ts create mode 100644 packages/lib/di/modules/Host.ts create mode 100644 packages/lib/di/modules/LuckyUser.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 4cc8b9ce90..abadfb2df6 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.321", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.333", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts index 344b603230..72b291dd99 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/emails/team-emails.e2e-spec.ts @@ -1,21 +1,18 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; -import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output"; import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output"; import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; -import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { UsersModule } from "@/modules/users/users.module"; -import { createParamDecorator, INestApplication } from "@nestjs/common"; +import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import { User } from "@prisma/client"; import * as request from "supertest"; -import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; @@ -28,23 +25,24 @@ import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; import { - OrganizerScheduledEmail, - AttendeeScheduledEmail, - OrganizerRescheduledEmail, - AttendeeRescheduledEmail, - OrganizerCancelledEmail, AttendeeCancelledEmail, - OrganizerReassignedEmail, + AttendeeRescheduledEmail, + AttendeeScheduledEmail, AttendeeUpdatedEmail, + OrganizerCancelledEmail, + OrganizerReassignedEmail, + OrganizerRescheduledEmail, + OrganizerScheduledEmail, } from "@calcom/platform-libraries/emails"; import { - CreateBookingInput_2024_08_13, BookingOutput_2024_08_13, + CancelBookingInput_2024_08_13, + CreateBookingInput_2024_08_13, RescheduleBookingInput_2024_08_13, } from "@calcom/platform-types"; -import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types"; import { Team } from "@calcom/prisma/client"; +// Mock all email sending prototypes jest .spyOn(AttendeeScheduledEmail.prototype, "getHtml") .mockImplementation(() => Promise.resolve("

email

")); @@ -70,29 +68,21 @@ jest .spyOn(AttendeeUpdatedEmail.prototype, "getHtml") .mockImplementation(() => Promise.resolve("

email

")); +// Type definitions for test setup data type EmailSetup = { team: Team; member1: User; member2: User; - collectiveEventType: { - id: number; - createdBookingUid: string; - rescheduledBookingUid: string; - }; - roundRobinEventType: { - id: number; - createdBookingUid: string; - rescheduledBookingUid: string; - currentHostId: number; - }; + collectiveEventType: { id: number }; + roundRobinEventType: { id: number }; }; describe("Bookings Endpoints 2024-08-13 team emails", () => { let app: INestApplication; let organization: Team; + // Fixtures for database interactions let userRepositoryFixture: UserRepositoryFixture; - let bookingsRepositoryFixture: BookingsRepositoryFixture; let schedulesService: SchedulesService_2024_04_15; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; @@ -101,11 +91,17 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { let membershipsRepositoryFixture: MembershipRepositoryFixture; let hostsRepositoryFixture: HostsRepositoryFixture; + // Setup data for tests let emailsEnabledSetup: EmailSetup; let emailsDisabledSetup: EmailSetup; const authEmail = "team-emails-2024-08-13-user-admin@example.com"; + // Utility function to check response data type + const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + }; + beforeAll(async () => { const moduleRef = await withApiAuth( authEmail, @@ -119,8 +115,8 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { }) .compile(); + // Initialize fixtures and services userRepositoryFixture = new UserRepositoryFixture(moduleRef); - bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); @@ -129,860 +125,410 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => { hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); + // Create a base organization for all tests organization = await teamRepositoryFixture.create({ name: `team-emails-organization-${randomString()}` }); - await setupEnabledEmails(); - await setupDisabledEmails(); + // Set up two distinct environments: one with emails enabled, one disabled + emailsEnabledSetup = await setupTestEnvironment(true); + emailsDisabledSetup = await setupTestEnvironment(false); await userRepositoryFixture.create({ email: authEmail, - organization: { - connect: { - id: organization.id, - }, - }, + organization: { connect: { id: organization.id } }, }); app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); - await app.init(); }); - async function setupEnabledEmails() { - const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true); - + // Helper function to create a complete test environment + async function setupTestEnvironment(emailsEnabled: boolean): Promise { + const oAuthClient = await createOAuthClient(organization.id, emailsEnabled); const team = await teamRepositoryFixture.create({ name: `team-emails-team-${randomString()}`, - isOrganization: false, parent: { connect: { id: organization.id } }, - createdByOAuthClient: { - connect: { - id: oAuthClientEmailsEnabled.id, - }, - }, + createdByOAuthClient: { connect: { id: oAuthClient.id } }, }); - const member1 = await userRepositoryFixture.create({ - email: `team-emails-2024-08-13-member1-${randomString()}@api.com`, - platformOAuthClients: { - connect: { - id: oAuthClientEmailsEnabled.id, - }, - }, - }); + const [member1, member2] = await Promise.all([ + createTeamMember(oAuthClient.id), + createTeamMember(oAuthClient.id), + ]); - const member2 = await userRepositoryFixture.create({ - email: `team-emails-2024-08-13-member2-${randomString()}@api.com`, - platformOAuthClients: { - connect: { - id: oAuthClientEmailsEnabled.id, - }, - }, - }); + await Promise.all([ + membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member1.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }), + membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: member2.id } }, + team: { connect: { id: team.id } }, + accepted: true, + }), + ]); - const userSchedule: CreateScheduleInput_2024_04_15 = { - name: `team-emails-2024-08-13-schedule-${randomString()}`, - timeZone: "Europe/Rome", - isDefault: true, - }; - await schedulesService.createUserSchedule(member1.id, userSchedule); - await schedulesService.createUserSchedule(member2.id, userSchedule); + const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]); + const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]); - await profileRepositoryFixture.create({ - uid: `usr-${member1.id}`, - username: member1.email, - organization: { - connect: { - id: organization.id, - }, - }, - user: { - connect: { - id: member1.id, - }, - }, - }); - - await profileRepositoryFixture.create({ - uid: `usr-${member2.id}`, - username: member2.email, - organization: { - connect: { - id: organization.id, - }, - }, - user: { - connect: { - id: member2.id, - }, - }, - }); - - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: member1.id } }, - team: { connect: { id: team.id } }, - accepted: true, - }); - - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: member2.id } }, - team: { connect: { id: team.id } }, - accepted: true, - }); - - const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({ - schedulingType: "COLLECTIVE", - team: { - connect: { id: team.id }, - }, - title: `team-emails-2024-08-13-event-type-${randomString()}`, - slug: `team-emails-2024-08-13-event-type-${randomString()}`, - length: 60, - assignAllTeamMembers: true, - bookingFields: [], - locations: [], - }); - - await hostsRepositoryFixture.create({ - isFixed: true, - user: { - connect: { - id: member1.id, - }, - }, - eventType: { - connect: { - id: collectiveEvent.id, - }, - }, - }); - - await hostsRepositoryFixture.create({ - isFixed: true, - user: { - connect: { - id: member2.id, - }, - }, - eventType: { - connect: { - id: collectiveEvent.id, - }, - }, - }); - - const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({ - schedulingType: "ROUND_ROBIN", - team: { - connect: { id: team.id }, - }, - title: `team-emails-2024-08-13-event-type-${randomString()}`, - slug: `team-emails-2024-08-13-event-type-${randomString()}`, - length: 60, - assignAllTeamMembers: false, - bookingFields: [], - locations: [], - }); - - await hostsRepositoryFixture.create({ - isFixed: false, - user: { - connect: { - id: member1.id, - }, - }, - eventType: { - connect: { - id: roundRobinEvent.id, - }, - }, - }); - - await hostsRepositoryFixture.create({ - isFixed: false, - user: { - connect: { - id: member2.id, - }, - }, - eventType: { - connect: { - id: roundRobinEvent.id, - }, - }, - }); - - emailsEnabledSetup = { + return { team, - member1: member1, - member2: member2, - collectiveEventType: { - id: collectiveEvent.id, - createdBookingUid: "", - rescheduledBookingUid: "", - }, - roundRobinEventType: { - id: roundRobinEvent.id, - createdBookingUid: "", - rescheduledBookingUid: "", - currentHostId: 0, - }, + member1, + member2, + collectiveEventType: { id: collectiveEvent.id }, + roundRobinEventType: { id: roundRobinEvent.id }, }; } - async function setupDisabledEmails() { - const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false); - - const team = await teamRepositoryFixture.create({ - name: `team-emails-2024-08-13-team-${randomString()}`, - isOrganization: false, - parent: { connect: { id: organization.id } }, - createdByOAuthClient: { - connect: { - id: oAuthClientEmailsDisabled.id, - }, - }, + // Helper to create a user and their profile/schedule + async function createTeamMember(oauthClientId: string) { + const member = await userRepositoryFixture.create({ + email: `team-emails-member-${randomString()}@api.com`, + platformOAuthClients: { connect: { id: oauthClientId } }, }); - - const member1 = await userRepositoryFixture.create({ - email: `team-emails-2024-08-13-member1-${randomString()}@api.com`, - platformOAuthClients: { - connect: { - id: oAuthClientEmailsDisabled.id, - }, - }, - }); - - const member2 = await userRepositoryFixture.create({ - email: `team-emails-2024-08-13-member2-${randomString()}@api.com`, - platformOAuthClients: { - connect: { - id: oAuthClientEmailsDisabled.id, - }, - }, - }); - const userSchedule: CreateScheduleInput_2024_04_15 = { - name: `team-emails-2024-08-13-schedule-${randomString()}`, + name: `schedule-${randomString()}`, timeZone: "Europe/Rome", isDefault: true, }; - await schedulesService.createUserSchedule(member1.id, userSchedule); - await schedulesService.createUserSchedule(member2.id, userSchedule); - + await schedulesService.createUserSchedule(member.id, userSchedule); await profileRepositoryFixture.create({ - uid: `usr-${member1.id}`, - username: member1.email, - organization: { - connect: { - id: organization.id, - }, - }, - user: { - connect: { - id: member1.id, - }, - }, + uid: `usr-${member.id}`, + username: member.email, + organization: { connect: { id: organization.id } }, + user: { connect: { id: member.id } }, }); - - await profileRepositoryFixture.create({ - uid: `usr-${member2.id}`, - username: member2.email, - organization: { - connect: { - id: organization.id, - }, - }, - user: { - connect: { - id: member2.id, - }, - }, - }); - - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: member1.id } }, - team: { connect: { id: team.id } }, - accepted: true, - }); - - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: member2.id } }, - team: { connect: { id: team.id } }, - accepted: true, - }); - - const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({ - schedulingType: "COLLECTIVE", - team: { - connect: { id: team.id }, - }, - title: `team-emails-2024-08-13-event-type-${randomString()}`, - slug: `team-emails-2024-08-13-event-type-${randomString()}`, - length: 60, - assignAllTeamMembers: true, - bookingFields: [], - locations: [], - }); - - await hostsRepositoryFixture.create({ - isFixed: true, - user: { - connect: { - id: member1.id, - }, - }, - eventType: { - connect: { - id: collectiveEvent.id, - }, - }, - }); - - await hostsRepositoryFixture.create({ - isFixed: true, - user: { - connect: { - id: member2.id, - }, - }, - eventType: { - connect: { - id: collectiveEvent.id, - }, - }, - }); - - const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({ - schedulingType: "ROUND_ROBIN", - team: { - connect: { id: team.id }, - }, - title: `team-emails-2024-08-13-event-type-${randomString()}`, - slug: `team-emails-2024-08-13-event-type-${randomString()}`, - length: 60, - assignAllTeamMembers: false, - bookingFields: [], - locations: [], - }); - - await hostsRepositoryFixture.create({ - isFixed: false, - user: { - connect: { - id: member1.id, - }, - }, - eventType: { - connect: { - id: roundRobinEvent.id, - }, - }, - }); - - await hostsRepositoryFixture.create({ - isFixed: false, - user: { - connect: { - id: member2.id, - }, - }, - eventType: { - connect: { - id: roundRobinEvent.id, - }, - }, - }); - - emailsDisabledSetup = { - team, - member1: member1, - member2: member2, - collectiveEventType: { - id: collectiveEvent.id, - createdBookingUid: "", - rescheduledBookingUid: "", - }, - roundRobinEventType: { - id: roundRobinEvent.id, - createdBookingUid: "", - rescheduledBookingUid: "", - currentHostId: 0, - }, - }; + return member; } + // Helper to create an event type and assign hosts + async function createEventType(type: "COLLECTIVE" | "ROUND_ROBIN", teamId: number, hostIds: number[]) { + const eventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: type, + team: { connect: { id: teamId } }, + title: `event-type-${randomString()}`, + slug: `event-type-${randomString()}`, + length: 60, + assignAllTeamMembers: type === "COLLECTIVE", + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + }); + await Promise.all( + hostIds.map((userId) => + hostsRepositoryFixture.create({ + isFixed: type === "COLLECTIVE", + user: { connect: { id: userId } }, + eventType: { connect: { id: eventType.id } }, + }) + ) + ); + return eventType; + } + + // Helper to create an OAuth client async function createOAuthClient(organizationId: number, emailsEnabled: boolean) { - const data = { - logo: "logo-url", - name: "name", - redirectUris: ["http://localhost:5555"], - permissions: 32, - areEmailsEnabled: emailsEnabled, - }; - const secret = "secret"; - - const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); - return client; + return oauthClientRepositoryFixture.create( + organizationId, + { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + areEmailsEnabled: emailsEnabled, + }, + "secret" + ); } - beforeEach(async () => { + beforeEach(() => { jest.clearAllMocks(); }); describe("OAuth client team bookings - emails disabled", () => { - describe("book", () => { - it("should not send an email when booking collective event", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), - eventTypeId: emailsDisabledSetup.collectiveEventType.id, - attendee: { - name: "Mr Proper", - email: "mr_proper@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - bookingFieldsResponses: { - customField: "customValue", - }, - metadata: { - userId: "100", - }, - }; - - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - emailsDisabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } - }); - }); - - it("should not send an email when booking round robin event", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), - eventTypeId: emailsDisabledSetup.roundRobinEventType.id, - attendee: { - name: "Mr Proper", - email: "mr_proper@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - bookingFieldsResponses: { - customField: "customValue", - }, - metadata: { - userId: "100", - }, - }; - - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - emailsDisabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid; - emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } - }); - }); - }); - - describe("reschedule", () => { - it("should not send an email when rescheduling collective booking", async () => { - const body: RescheduleBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), - reschedulingReason: "Flying to mars that day", - }; - - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.createdBookingUid}/reschedule`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - emailsDisabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid; - }); - }); - - it("should not send an email when rescheduling round robin booking", async () => { - const body: RescheduleBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), - reschedulingReason: "Flying to mars that day", - }; - - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.createdBookingUid}/reschedule`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid; - emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; - }); - }); - }); - - describe("reassign", () => { - it("should not send an email when manually reassigning round robin booking", async () => { - const reassignToId = - emailsDisabledSetup.roundRobinEventType.currentHostId === emailsDisabledSetup.member1.id - ? emailsDisabledSetup.member2.id - : emailsDisabledSetup.member1.id; - - return request(app.getHttpServer()) - .post( - `/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}` - ) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); - emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId; - }); - }); - - it("should not send an email when automatically reassigning round robin booking", async () => { - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); - }); - }); - }); - - describe("cancel", () => { - it("should not send an email when cancelling a collective booking", async () => { - const body: CancelBookingInput_2024_08_13 = { - cancellationReason: "Going on a vacation", - }; - - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - }); - }); - }); - - it("should not send an email when cancelling a round robin booking", async () => { - const body: CancelBookingInput_2024_08_13 = { - cancellationReason: "Going on a vacation", + it("should handle the full booking lifecycle for a collective event without sending emails", async () => { + // --- 1. Book Event --- + const createBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.collectiveEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, }; + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - }); + expect(createResponse.status).toBe(201); + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + expect(createResponseBody.status).toEqual(SUCCESS_STATUS); + expect(responseDataIsBooking(createResponseBody.data)).toBe(true); + const bookingUid = (createResponseBody.data as BookingOutput_2024_08_13).uid; + + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 2. Reschedule Event --- + const rescheduleBody: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), + reschedulingReason: "Flying to mars that day", + }; + const rescheduleResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/reschedule`) + .send(rescheduleBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(rescheduleResponse.status).toBe(201); + const rescheduleResponseBody: RescheduleBookingOutput_2024_08_13 = rescheduleResponse.body; + expect(rescheduleResponseBody.status).toEqual(SUCCESS_STATUS); + const rescheduledBookingUid = rescheduleResponseBody.data.uid; + + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 3. Cancel Event --- + const cancelBody: CancelBookingInput_2024_08_13 = { cancellationReason: "Going on a vacation" }; + const cancelResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/cancel`) + .send(cancelBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(cancelResponse.status).toBe(200); + expect(cancelResponse.body.status).toEqual(SUCCESS_STATUS); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + }); + + it("should handle the full booking lifecycle for a round-robin event without sending emails", async () => { + // --- 1. Book Event --- + const createBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsDisabledSetup.roundRobinEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(createResponse.status).toBe(201); + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + expect(responseDataIsBooking(createResponseBody.data)).toBe(true); + const bookingUid = (createResponseBody.data as BookingOutput_2024_08_13).uid; + let currentHostId = (createResponseBody.data as BookingOutput_2024_08_13).hosts[0].id; + expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 2. Reschedule Event --- + const rescheduleBody: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), + }; + const rescheduleResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/reschedule`) + .send(rescheduleBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(rescheduleResponse.status).toBe(201); + const rescheduleResponseBody: RescheduleBookingOutput_2024_08_13 = rescheduleResponse.body; + const rescheduledBookingUid = rescheduleResponseBody.data.uid; + currentHostId = rescheduleResponseBody.data.hosts[0].id; + expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 3. Manual Reassign --- + const reassignToId = + currentHostId === emailsDisabledSetup.member1.id + ? emailsDisabledSetup.member2.id + : emailsDisabledSetup.member1.id; + const manualReassignResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(manualReassignResponse.status).toBe(200); + expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 4. Automatic Reassign --- + const autoReassignResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/reassign`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(autoReassignResponse.status).toBe(200); + expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); + + // --- 5. Cancel Event --- + const cancelBody: CancelBookingInput_2024_08_13 = { cancellationReason: "Vacation" }; + const cancelResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/cancel`) + .send(cancelBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(cancelResponse.status).toBe(200); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); }); }); describe("OAuth client team bookings - emails enabled", () => { - beforeEach(async () => { + it("should handle the full booking lifecycle for a collective event and send emails", async () => { + // --- 1. Book Event --- + const createBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.collectiveEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(createResponse.status).toBe(201); + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + expect(responseDataIsBooking(createResponseBody.data)).toBe(true); + const bookingUid = (createResponseBody.data as BookingOutput_2024_08_13).uid; + + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + + // --- 2. Reschedule Event --- + const rescheduleBody: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), + }; + const rescheduleResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/reschedule`) + .send(rescheduleBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(rescheduleResponse.status).toBe(201); + const rescheduledBookingUid = (rescheduleResponse.body as RescheduleBookingOutput_2024_08_13).data.uid; + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + + // --- 3. Cancel Event --- + const cancelBody: CancelBookingInput_2024_08_13 = { cancellationReason: "Vacation" }; + const cancelResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/cancel`) + .send(cancelBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(cancelResponse.status).toBe(200); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + }); + + it("should handle the full booking lifecycle for a round-robin event and send emails", async () => { + // --- 1. Book Event --- + const createBody: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), + eventTypeId: emailsEnabledSetup.roundRobinEventType.id, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + }; + const createResponse = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(createBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); + + expect(createResponse.status).toBe(201); + const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body; + expect(responseDataIsBooking(createResponseBody.data)).toBe(true); + const bookingUid = (createResponseBody.data as BookingOutput_2024_08_13).uid; + let currentHostId = (createResponseBody.data as BookingOutput_2024_08_13).hosts[0].id; + expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + + // --- 2. Reschedule Event --- jest.clearAllMocks(); - }); + const rescheduleBody: RescheduleBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), + }; + const rescheduleResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingUid}/reschedule`) + .send(rescheduleBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); - describe("book", () => { - it("should send an email when booking collective event", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(), - eventTypeId: emailsEnabledSetup.collectiveEventType.id, - attendee: { - name: "Mr Proper", - email: "mr_proper@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - bookingFieldsResponses: { - customField: "customValue", - }, - metadata: { - userId: "100", - }, - }; + expect(rescheduleResponse.status).toBe(201); + const rescheduleResponseBody: RescheduleBookingOutput_2024_08_13 = rescheduleResponse.body; + const rescheduledBookingUid = rescheduleResponseBody.data.uid; + currentHostId = rescheduleResponseBody.data.hosts[0].id; + expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); // Old host gets cancellation + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); // New host gets scheduled - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - emailsEnabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } - }); - }); + // --- 3. Manual Reassign --- + jest.clearAllMocks(); + const originalHostId = currentHostId; + const reassignToId = + currentHostId === emailsEnabledSetup.member1.id + ? emailsEnabledSetup.member2.id + : emailsEnabledSetup.member1.id; + const hasOrganizerChanged = reassignToId !== originalHostId; - it("should send an email when booking round robin event", async () => { - const body: CreateBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(), - eventTypeId: emailsEnabledSetup.roundRobinEventType.id, - attendee: { - name: "Mr Proper", - email: "mr_proper@gmail.com", - timeZone: "Europe/Rome", - language: "it", - }, - location: "https://meet.google.com/abc-def-ghi", - bookingFieldsResponses: { - customField: "customValue", - }, - metadata: { - userId: "100", - }, - }; + const manualReassignResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); - return request(app.getHttpServer()) - .post("/v2/bookings") - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: CreateBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - if (responseDataIsBooking(responseBody.data)) { - expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - emailsEnabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid; - emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; - } else { - throw new Error( - "Invalid response data - expected booking but received array of possibly recurring bookings" - ); - } - }); - }); - }); + expect(manualReassignResponse.status).toBe(200); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); + if (hasOrganizerChanged) { + expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); + } else { + expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled(); + } - describe("reschedule", () => { - it("should send an email when rescheduling collective booking", async () => { - const body: RescheduleBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(), - reschedulingReason: "Flying to mars that day", - }; + // --- 4. Automatic Reassign --- + jest.clearAllMocks(); + const autoReassignResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/reassign`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.createdBookingUid}/reschedule`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); - emailsEnabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid; - }); - }); + expect(autoReassignResponse.status).toBe(200); + expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled(); + expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); - it("should send an email when rescheduling round robin booking", async () => { - const body: RescheduleBookingInput_2024_08_13 = { - start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(), - reschedulingReason: "Flying to mars that day", - }; + // --- 5. Cancel Event --- + jest.clearAllMocks(); + const cancelBody: CancelBookingInput_2024_08_13 = { cancellationReason: "Vacation" }; + const cancelResponse = await request(app.getHttpServer()) + .post(`/v2/bookings/${rescheduledBookingUid}/cancel`) + .send(cancelBody) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13); - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.createdBookingUid}/reschedule`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(201) - .then(async (response) => { - const responseBody: RescheduleBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled(); - - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); - emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid; - emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id; - }); - }); - }); - - describe("reassign", () => { - it("should send an email when manually reassigning round robin booking", async () => { - const reassignToId = - emailsEnabledSetup.roundRobinEventType.currentHostId === emailsEnabledSetup.member1.id - ? emailsEnabledSetup.member2.id - : emailsEnabledSetup.member1.id; - - return request(app.getHttpServer()) - .post( - `/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}` - ) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); - emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId; - }); - }); - - it("should send an email when automatically reassigning round robin booking", async () => { - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled(); - expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled(); - expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled(); - }); - }); - }); - - describe("cancel", () => { - it("should send an email when cancelling a collective booking", async () => { - const body: CancelBookingInput_2024_08_13 = { - cancellationReason: "Going on a vacation", - }; - - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); - }); - }); - - it("should send an email when cancelling round robin booking", async () => { - const body: CancelBookingInput_2024_08_13 = { - cancellationReason: "Going on a vacation", - }; - - return request(app.getHttpServer()) - .post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`) - .send(body) - .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) - .expect(200) - .then(async (response) => { - const responseBody: CancelBookingOutput_2024_08_13 = response.body; - expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toBeDefined(); - expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); - expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); - }); - }); + expect(cancelResponse.status).toBe(200); + expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled(); + expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled(); }); }); afterAll(async () => { + // Clean up database records await teamRepositoryFixture.delete(organization.id); await userRepositoryFixture.deleteByEmail(authEmail); await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email); + await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email); + await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email); await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member2.email); - await bookingsRepositoryFixture.deleteAllBookings( - emailsEnabledSetup.member1.id, - emailsEnabledSetup.member1.email - ); - await bookingsRepositoryFixture.deleteAllBookings( - emailsDisabledSetup.member1.id, - emailsDisabledSetup.member2.email - ); await app.close(); }); - - function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { - return !Array.isArray(data) && typeof data === "object" && data && "id" in data; - } }); diff --git a/apps/api/v2/src/lib/repositories/prisma-attribute.repository.ts b/apps/api/v2/src/lib/repositories/prisma-attribute.repository.ts new file mode 100644 index 0000000000..c479e8b947 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-attribute.repository.ts @@ -0,0 +1,12 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { PrismaAttributeRepository as PrismaAttributeRepositoryLib } from "@calcom/platform-libraries/repositories"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class PrismaAttributeRepository extends PrismaAttributeRepositoryLib { + constructor(private readonly dbWrite: PrismaWriteService) { + super(dbWrite.prisma as unknown as PrismaClient); + } +} diff --git a/apps/api/v2/src/lib/repositories/prisma-host.repository.ts b/apps/api/v2/src/lib/repositories/prisma-host.repository.ts new file mode 100644 index 0000000000..30d4d364a2 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-host.repository.ts @@ -0,0 +1,12 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { PrismaHostRepository as PrismaHostRepositoryLib } from "@calcom/platform-libraries/repositories"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class PrismaHostRepository extends PrismaHostRepositoryLib { + constructor(private readonly dbWrite: PrismaWriteService) { + super(dbWrite.prisma as unknown as PrismaClient); + } +} diff --git a/apps/api/v2/src/lib/services/lucky-user.service.ts b/apps/api/v2/src/lib/services/lucky-user.service.ts new file mode 100644 index 0000000000..9d05b066f5 --- /dev/null +++ b/apps/api/v2/src/lib/services/lucky-user.service.ts @@ -0,0 +1,27 @@ +import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository"; +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; +import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; +import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; +import { Injectable } from "@nestjs/common"; + +import { LuckyUserService as BaseLuckyUserService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class LuckyUserService extends BaseLuckyUserService { + constructor( + bookingRepository: PrismaBookingRepository, + hostRepository: PrismaHostRepository, + oooRepository: PrismaOOORepository, + userRepository: PrismaUserRepository, + attributeRepository: PrismaAttributeRepository + ) { + super({ + bookingRepository: bookingRepository, + hostRepository: hostRepository, + oooRepository: oooRepository, + userRepository: userRepository, + attributeRepository: attributeRepository, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts index 9958253a40..12ec49dd2f 100644 --- a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts @@ -15,7 +15,6 @@ export class ApiKeysRepositoryFixture { async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) { const keyString = randomBytes(16).toString("hex"); const hashedKey = createHash("sha256").update(keyString).digest("hex"); - console.log("createApiKey Fixutre", { hashedKey, keyString }); const apiKey = await this.prismaWriteClient.apiKey.create({ data: { userId, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 4aa5204b8f..96f9646585 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -54,6 +54,7 @@ import { } from "@calcom/lib/delegationCredential/server"; import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/BookingLimits"; import { getCacheService } from "@calcom/lib/di/containers/Cache"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getEventName, updateHostInEventName } from "@calcom/lib/event"; @@ -67,7 +68,6 @@ import logger from "@calcom/lib/logger"; import { handlePayment } from "@calcom/lib/payment/handlePayment"; import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { getLuckyUser } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; @@ -903,7 +903,8 @@ async function handler( memberId: eventTypeWithUsers.users[0].id ?? null, teamId: eventType.teamId, }); - const newLuckyUser = await getLuckyUser({ + const luckyUserService = getLuckyUserService(); + const newLuckyUser = await luckyUserService.getLuckyUser({ // find a lucky user that is not already in the luckyUsers array availableUsers: freeUsers, // only hosts from the same group diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index c0e3a11f48..a7f1606015 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -20,12 +20,12 @@ import { enrichHostsWithDelegationCredentials, enrichUserWithDelegationCredentialsIncludeServiceAccountKey, } from "@calcom/lib/delegationCredential/server"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getEventName } from "@calcom/lib/event"; import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import logger from "@calcom/lib/logger"; -import { getLuckyUser } from "@calcom/lib/server/getLuckyUser"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; @@ -153,8 +153,8 @@ export const roundRobinReassignment = async ({ }, roundRobinReassignLogger ); - - const reassignedRRHost = await getLuckyUser({ + const luckyUserService = getLuckyUserService(); + const reassignedRRHost = await luckyUserService.getLuckyUser({ availableUsers, eventType, allRRHosts: eventTypeHosts.filter((host) => !host.isFixed), // todo: only use hosts from virtual queue diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.test.ts b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts index c783772d0b..7167ec19d8 100644 --- a/packages/lib/bookings/filterHostsByLeadThreshold.test.ts +++ b/packages/lib/bookings/filterHostsByLeadThreshold.test.ts @@ -1,12 +1,13 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; +import type { Mock } from "vitest"; +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import prisma from "@calcom/prisma"; import { RRResetInterval } from "@calcom/prisma/client"; -import { getOrderedListOfLuckyUsers } from "../server/getLuckyUser"; import { filterHostsByLeadThreshold, errorCodes } from "./filterHostsByLeadThreshold"; -// Import the original Prisma client +const luckyUserService = getLuckyUserService(); // Mocking setup const prismaMock = { @@ -18,13 +19,19 @@ const prismaMock = { // Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead vi.spyOn(prisma.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy); -vi.mock("../server/getLuckyUser", () => ({ - getOrderedListOfLuckyUsers: vi.fn(), -})); +// This variable will hold our mock function +let getOrderedListOfLuckyUsersMock: Mock; + +beforeEach(() => { + // Clear all mocks and spies before each test + vi.clearAllMocks(); + // Spy on the real method and explicitly cast it as a Mock + getOrderedListOfLuckyUsersMock = vi.spyOn(luckyUserService, "getOrderedListOfLuckyUsers") as Mock; +}); afterEach(() => { - // Clear call history before each test to avoid cross-test interference - prismaMock.booking.groupBy.mockClear(); + // Restore all spies to their original implementation + vi.restoreAllMocks(); }); describe("filterHostByLeadThreshold", () => { @@ -100,7 +107,7 @@ describe("filterHostByLeadThreshold", () => { }, ]; - getOrderedListOfLuckyUsers.mockResolvedValue({ + getOrderedListOfLuckyUsersMock.mockResolvedValue({ perUserData: { bookingsCount: { 1: 10, 2: 6 }, }, @@ -157,7 +164,7 @@ describe("filterHostByLeadThreshold", () => { }, ]; - getOrderedListOfLuckyUsers.mockResolvedValue({ + getOrderedListOfLuckyUsersMock.mockResolvedValue({ perUserData: { bookingsCount: { 1: 7, 2: 5, 3: 0 }, weights: { 1: 100, 2: 50, 3: 20 }, diff --git a/packages/lib/bookings/filterHostsByLeadThreshold.ts b/packages/lib/bookings/filterHostsByLeadThreshold.ts index 3da2dbd71c..554cf1dfa9 100644 --- a/packages/lib/bookings/filterHostsByLeadThreshold.ts +++ b/packages/lib/bookings/filterHostsByLeadThreshold.ts @@ -3,8 +3,8 @@ import type { RRResetInterval, SelectedCalendar } from "@calcom/prisma/client"; import { RRTimestampBasis } from "@calcom/prisma/enums"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; -import type { RoutingFormResponse } from "../server/getLuckyUser"; -import { getOrderedListOfLuckyUsers } from "../server/getLuckyUser"; +import { getLuckyUserService } from "../di/containers/LuckyUser"; +import type { LuckyUserService, RoutingFormResponse } from "../server/getLuckyUser"; export const errorCodes = { MAX_LEAD_THRESHOLD_FALSY: "Max lead threshold should be null or > 1, not 0.", @@ -26,7 +26,9 @@ type BaseHost = { user: User; }; -type PerUserData = Awaited>["perUserData"]; +type PerUserData = Awaited< + ReturnType<(typeof LuckyUserService.prototype)["getOrderedListOfLuckyUsers"]> +>["perUserData"]; type WeightedPerUserData = Omit & { weights: NonNullable; calibrations: NonNullable; @@ -116,7 +118,8 @@ export const filterHostsByLeadThreshold = async >({ } // this needs the routing forms response too, because it needs to know what queue we are in - const orderedLuckyUsers = await getOrderedListOfLuckyUsers({ + const luckyUserService = getLuckyUserService(); + const orderedLuckyUsers = await luckyUserService.getOrderedListOfLuckyUsers({ availableUsers: [ { ...hosts[0].user, diff --git a/packages/lib/di/containers/LuckyUser.ts b/packages/lib/di/containers/LuckyUser.ts new file mode 100644 index 0000000000..8d3d049ce9 --- /dev/null +++ b/packages/lib/di/containers/LuckyUser.ts @@ -0,0 +1,25 @@ +import { createContainer } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "@calcom/lib/di/tokens"; +import type { LuckyUserService } from "@calcom/lib/server/getLuckyUser"; +import { prismaModule } from "@calcom/prisma/prisma.module"; + +import { attributeRepositoryModule } from "../modules/Attribute"; +import { bookingRepositoryModule } from "../modules/Booking"; +import { hostRepositoryModule } from "../modules/Host"; +import { luckyUserServiceModule } from "../modules/LuckyUser"; +import { oooRepositoryModule } from "../modules/Ooo"; +import { userRepositoryModule } from "../modules/User"; + +const container = createContainer(); +container.load(DI_TOKENS.PRISMA_MODULE, prismaModule); +container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule); +container.load(DI_TOKENS.HOST_REPOSITORY_MODULE, hostRepositoryModule); +container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule); +container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule); +container.load(DI_TOKENS.ATTRIBUTE_REPOSITORY_MODULE, attributeRepositoryModule); +container.load(DI_TOKENS.LUCKY_USER_SERVICE_MODULE, luckyUserServiceModule); + +export function getLuckyUserService() { + return container.get(DI_TOKENS.LUCKY_USER_SERVICE); +} diff --git a/packages/lib/di/modules/Attribute.ts b/packages/lib/di/modules/Attribute.ts new file mode 100644 index 0000000000..8906b0d381 --- /dev/null +++ b/packages/lib/di/modules/Attribute.ts @@ -0,0 +1,9 @@ +import { createModule } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "@calcom/lib/di/tokens"; +import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; + +export const attributeRepositoryModule = createModule(); +attributeRepositoryModule + .bind(DI_TOKENS.ATTRIBUTE_REPOSITORY) + .toClass(PrismaAttributeRepository, [DI_TOKENS.PRISMA_CLIENT]); diff --git a/packages/lib/di/modules/Host.ts b/packages/lib/di/modules/Host.ts new file mode 100644 index 0000000000..21449ef161 --- /dev/null +++ b/packages/lib/di/modules/Host.ts @@ -0,0 +1,7 @@ +import { createModule } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "@calcom/lib/di/tokens"; +import { HostRepository } from "@calcom/lib/server/repository/host"; + +export const hostRepositoryModule = createModule(); +hostRepositoryModule.bind(DI_TOKENS.HOST_REPOSITORY).toClass(HostRepository, [DI_TOKENS.PRISMA_CLIENT]); diff --git a/packages/lib/di/modules/LuckyUser.ts b/packages/lib/di/modules/LuckyUser.ts new file mode 100644 index 0000000000..bf68f6ea8c --- /dev/null +++ b/packages/lib/di/modules/LuckyUser.ts @@ -0,0 +1,13 @@ +import { createModule } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "@calcom/lib/di/tokens"; +import { LuckyUserService } from "@calcom/lib/server/getLuckyUser"; + +export const luckyUserServiceModule = createModule(); +luckyUserServiceModule.bind(DI_TOKENS.LUCKY_USER_SERVICE).toClass(LuckyUserService, { + bookingRepository: DI_TOKENS.BOOKING_REPOSITORY, + hostRepository: DI_TOKENS.HOST_REPOSITORY, + oooRepository: DI_TOKENS.OOO_REPOSITORY, + userRepository: DI_TOKENS.USER_REPOSITORY, + attributeRepository: DI_TOKENS.ATTRIBUTE_REPOSITORY, +}); diff --git a/packages/lib/di/tokens.ts b/packages/lib/di/tokens.ts index 83aaa79c48..2e4cb14461 100644 --- a/packages/lib/di/tokens.ts +++ b/packages/lib/di/tokens.ts @@ -45,4 +45,10 @@ export const DI_TOKENS = { NO_SLOTS_NOTIFICATION_SERVICE_MODULE: Symbol("NoSlotsNotificationServiceModule"), MEMBERSHIP_REPOSITORY: Symbol("MembershipRepository"), MEMBERSHIP_REPOSITORY_MODULE: Symbol("MembershipRepositoryModule"), + LUCKY_USER_SERVICE: Symbol("LuckyUserService"), + LUCKY_USER_SERVICE_MODULE: Symbol("LuckyUserServiceModule"), + HOST_REPOSITORY: Symbol("HostRepository"), + HOST_REPOSITORY_MODULE: Symbol("HostRepositoryModule"), + ATTRIBUTE_REPOSITORY: Symbol("AttributeRepository"), + ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"), }; diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index 0076e510c3..ba806e118a 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -1,9 +1,9 @@ import { describe, it, vi, expect, afterEach, beforeEach, beforeAll, afterAll } from "vitest"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import prisma from "@calcom/prisma"; -import { getLuckyUser, getOrderedListOfLuckyUsers } from "./getLuckyUser"; - +const luckyUserService = getLuckyUserService(); let commonEventTypeId: number; const userIds: number[] = []; @@ -202,7 +202,7 @@ describe("getLuckyUser Integration tests", () => { const organizerThatShowedUp = organizerHostThatShowedUp.user; const organizerThatDidntShowUp = organizerHostThatDidntShowUp.user; - const luckyUser = await getLuckyUser({ + const luckyUser = await luckyUserService.getLuckyUser({ availableUsers: [organizerThatShowedUp, organizerThatDidntShowUp], eventType: { id: commonEventTypeId, @@ -262,7 +262,7 @@ describe("getLuckyUser Integration tests", () => { const organizerWhoseAttendeeDidntShowUp = organizerHostWhoseAttendeeDidntShowUp.user; expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: [organizerWhoseAttendeeShowedUp, organizerWhoseAttendeeDidntShowUp], eventType: { id: commonEventTypeId, @@ -359,7 +359,7 @@ describe("getLuckyUser Integration tests", () => { const organizerWhoWasAttendeeAndDidntShowUp = organizerHostWhoWasAttendeeAndDidntShowUp.user; expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: [ organizerWhoseAttendeeShowedUp, fixedHostOrganizerWhoseAttendeeDidNotShowUp, @@ -426,7 +426,7 @@ describe("getLuckyUser Integration tests", () => { const userWithBookingThatHappenedEarlier = hostWithBookingThatHappenedEarlier.user; expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: [userWithBookingThatHappenedLater, userWithBookingThatHappenedEarlier], eventType: { id: commonEventTypeId, @@ -469,7 +469,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { const user2 = host2.user; const user3 = host3.user; - const { users: luckyUsers } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers } = await luckyUserService.getOrderedListOfLuckyUsers({ availableUsers: [user2, user1, user3], eventType: { id: commonEventTypeId, @@ -482,7 +482,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { expectLuckyUsers(luckyUsers, [user2, user1, user3]); - const { users: luckyUsers2 } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers2 } = await luckyUserService.getOrderedListOfLuckyUsers({ availableUsers: [user3, user1, user2], eventType: { id: commonEventTypeId, @@ -524,7 +524,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { const user2WithWeight100 = host3WithWeight100.user; const allRRHosts = [host1WithWeight100, host2WithWeight200, host3WithWeight100]; - const { users: luckyUsers } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers } = await luckyUserService.getOrderedListOfLuckyUsers({ availableUsers: [userWithHighestWeight, user1WithWeight100, user2WithWeight100], eventType: { id: commonEventTypeId, @@ -544,7 +544,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { user2WithWeight100, ]); - const { users: luckyUsers2 } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers2 } = await luckyUserService.getOrderedListOfLuckyUsers({ availableUsers: [user2WithWeight100, userWithHighestWeight, user1WithWeight100], eventType: { id: commonEventTypeId, @@ -622,7 +622,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { routingFormResponse: null, }; - const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers, perUserData } = await luckyUserService.getOrderedListOfLuckyUsers({ ...getLuckUserParams, availableUsers: [getLuckUserParams.availableUsers[0], ...getLuckUserParams.availableUsers.slice(1)], }); @@ -705,7 +705,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { routingFormResponse: null, }; - const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers, perUserData } = await luckyUserService.getOrderedListOfLuckyUsers({ ...getLuckUserParams, availableUsers: [getLuckUserParams.availableUsers[0], ...getLuckUserParams.availableUsers.slice(1)], }); @@ -781,7 +781,7 @@ describe("getOrderedListOfLuckyUsers Integration tests", () => { routingFormResponse: null, }; - const { users: luckyUsers, perUserData } = await getOrderedListOfLuckyUsers({ + const { users: luckyUsers, perUserData } = await luckyUserService.getOrderedListOfLuckyUsers({ ...getLuckyUserParams, availableUsers: [getLuckyUserParams.availableUsers[0], ...getLuckyUserParams.availableUsers.slice(1)], }); diff --git a/packages/lib/server/getLuckyUser.test.ts b/packages/lib/server/getLuckyUser.test.ts index 7d14f6debd..a68043b1f6 100644 --- a/packages/lib/server/getLuckyUser.test.ts +++ b/packages/lib/server/getLuckyUser.test.ts @@ -5,15 +5,13 @@ import { v4 as uuid } from "uuid"; import { expect, it, describe, vi, beforeAll } from "vitest"; import dayjs from "@calcom/dayjs"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import { buildUser, buildBooking } from "@calcom/lib/test/builder"; import { AttributeType, RRResetInterval, RRTimestampBasis } from "@calcom/prisma/enums"; -import { - getLuckyUser, - prepareQueuesAndAttributesData, - getIntervalStartDate, - getIntervalEndDate, -} from "./getLuckyUser"; +import { getIntervalStartDate, getIntervalEndDate } from "./getLuckyUser"; + +const luckyUserService = getLuckyUserService(); type NonEmptyArray = [T, ...T[]]; type GetLuckyUserAvailableUsersType = NonEmptyArray>; @@ -58,18 +56,18 @@ it("can find lucky user with maximize availability", async () => { CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] }); prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]); - // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(users); prismaMock.host.findMany.mockResolvedValue([]); prismaMock.booking.findMany.mockResolvedValue([]); await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: false, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts: [], routingFormResponse: null, @@ -110,22 +108,22 @@ it("can find lucky user with maximize availability and priority ranking", async CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] }); prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]); - // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(users); prismaMock.host.findMany.mockResolvedValue([]); prismaMock.booking.findMany.mockResolvedValue([]); // both users have medium priority (one user has no priority set, default to medium) so pick least recently booked await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: false, team: { rrResetInterval: RRResetInterval.MONTH, - team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + rrTimestampBasis: RRTimestampBasis.CREATED_AT, }, + includeNoShowInRRCalculation: false, }, allRRHosts: [], routingFormResponse: null, @@ -171,18 +169,18 @@ it("can find lucky user with maximize availability and priority ranking", async }); const usersWithPriorities: GetLuckyUserAvailableUsersType = [userLowest, userMedium, userHighest]; - // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(usersWithPriorities); prismaMock.booking.findMany.mockResolvedValue([]); prismaMock.host.findMany.mockResolvedValue([]); // pick the user with the highest priority await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: usersWithPriorities, eventType: { id: 1, isRRWeightsEnabled: false, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts: [], routingFormResponse: null, @@ -232,19 +230,19 @@ it("can find lucky user with maximize availability and priority ranking", async userHighLeastRecentBooking, userHighRecentBooking, ]; - // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(usersWithSamePriorities); prismaMock.booking.findMany.mockResolvedValue([]); prismaMock.host.findMany.mockResolvedValue([]); // pick the least recently booked user of the two with the highest priority await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: usersWithSamePriorities, eventType: { id: 1, isRRWeightsEnabled: false, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts: [], routingFormResponse: null, @@ -318,24 +316,35 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, ]; await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, @@ -426,24 +435,35 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, ]; await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.DAY, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, @@ -534,24 +554,35 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, ]; await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.DAY, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, @@ -589,12 +620,22 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, @@ -641,17 +682,18 @@ describe("maximize availability and weights", () => { ]); await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, }) - ).resolves.toStrictEqual(users[1]); // user[1] has one more bookings, but user[0] has calibration 2 + ).resolves.toStrictEqual(users[1]); const queryArgs = prismaMock.booking.findMany.mock.calls[0][0]; @@ -695,12 +737,22 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, @@ -751,17 +803,18 @@ describe("maximize availability and weights", () => { ]); await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, }) - ).resolves.toStrictEqual(users[1]); // user[1] has one more booking, but user[0] has calibration 2 + ).resolves.toStrictEqual(users[1]); const queryArgs = prismaMock.booking.findMany.mock.calls[0][0]; @@ -809,12 +862,22 @@ describe("maximize availability and weights", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: middleOfMonth, }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, @@ -823,7 +886,6 @@ describe("maximize availability and weights", () => { CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] }); prismaMock.outOfOfficeEntry.findMany.mockResolvedValue([]); - // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(users); prismaMock.host.findMany.mockResolvedValue([ { @@ -832,8 +894,10 @@ describe("maximize availability and weights", () => { createdAt: allRRHosts[0].createdAt, }, ]); - // findMany bookings are BEFORE the new host (user 1) was added, calibration=2. - prismaMock.booking.findMany.mockResolvedValue([ + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([ buildBooking({ id: 4, userId: 2, @@ -846,19 +910,24 @@ describe("maximize availability and weights", () => { }), ]); await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, }) ).resolves.toStrictEqual(users[1]); - // findMany bookings are AFTER the new host (user 1) was added, calibration=0. - prismaMock.booking.findMany.mockResolvedValue([ + + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([]); + prismaMock.booking.findMany.mockResolvedValueOnce([ + // Mock 6: All hosts buildBooking({ id: 4, userId: 2, @@ -871,12 +940,13 @@ describe("maximize availability and weights", () => { }), ]); await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, isRRWeightsEnabled: true, team: { rrResetInterval: RRResetInterval.MONTH, rrTimestampBasis: RRTimestampBasis.CREATED_AT }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse: null, @@ -1019,11 +1089,16 @@ describe("attribute weights and virtual queues", () => { ], }); - const queuesAndAttributesData = await prepareQueuesAndAttributesData({ + const queuesAndAttributesData = await luckyUserService.prepareQueuesAndAttributesData({ eventType: { id: 1, isRRWeightsEnabled: true, - team: { parentId: 1, rrResetInterval: RRResetInterval.DAY }, + team: { + parentId: 1, + rrResetInterval: RRResetInterval.DAY, + rrTimestampBasis: RRTimestampBasis.CREATED_AT, + }, + includeNoShowInRRCalculation: false, }, routingFormResponse, allRRHosts: [ @@ -1032,7 +1107,7 @@ describe("attribute weights and virtual queues", () => { id: 1, email: "test1@example.com", credentials: [], - selectedCalendars: [], + userLevelSelectedCalendars: [], }, createdAt: new Date(), weight: 10, @@ -1042,7 +1117,7 @@ describe("attribute weights and virtual queues", () => { id: 2, email: "test2@example.com", credentials: [], - selectedCalendars: [], + userLevelSelectedCalendars: [], }, createdAt: new Date(), weight: 150, @@ -1158,11 +1233,16 @@ describe("attribute weights and virtual queues", () => { ], }); - const queuesAndAttributesData = await prepareQueuesAndAttributesData({ + const queuesAndAttributesData = await luckyUserService.prepareQueuesAndAttributesData({ eventType: { id: 1, isRRWeightsEnabled: true, - team: { parentId: 1, rrResetInterval: RRResetInterval.DAY }, + team: { + parentId: 1, + rrResetInterval: RRResetInterval.DAY, + rrTimestampBasis: RRTimestampBasis.CREATED_AT, + }, + includeNoShowInRRCalculation: false, }, routingFormResponse, allRRHosts: [ @@ -1171,7 +1251,7 @@ describe("attribute weights and virtual queues", () => { id: 1, email: "test1@example.com", credentials: [], - selectedCalendars: [], + userLevelSelectedCalendars: [], }, createdAt: new Date(), weight: 10, @@ -1181,7 +1261,7 @@ describe("attribute weights and virtual queues", () => { id: 2, email: "test2@example.com", credentials: [], - selectedCalendars: [], + userLevelSelectedCalendars: [], }, createdAt: new Date(), weight: 150, @@ -1300,7 +1380,7 @@ describe("attribute weights and virtual queues", () => { response: { [fieldId]: { label: "company_size", - value: attributeOptionIdFirst, // booking part of virtual queue + value: attributeOptionIdFirst, }, }, createdAt: new Date("2022-01-25T06:30:00.000Z"), @@ -1319,7 +1399,7 @@ describe("attribute weights and virtual queues", () => { response: { [fieldId]: { label: "company_size", - value: attributeOptionIdFirst, // booking part of virtual queue + value: attributeOptionIdFirst, }, }, createdAt: new Date("2022-01-25T05:30:00.000Z"), @@ -1338,7 +1418,7 @@ describe("attribute weights and virtual queues", () => { response: { [fieldId]: { label: "company_size", - value: attributeOptionIdSecond, // different queue, booking doesn't count + value: attributeOptionIdSecond, }, }, createdAt: new Date("2022-01-25T05:30:00.000Z"), @@ -1377,19 +1457,29 @@ describe("attribute weights and virtual queues", () => { const allRRHosts = [ { - user: { id: users[0].id, email: users[0].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[0].id, + email: users[0].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[0].weight, createdAt: new Date(0), }, { - user: { id: users[1].id, email: users[1].email, credentials: [], selectedCalendars: [] }, + user: { + id: users[1].id, + email: users[1].email, + credentials: [], + userLevelSelectedCalendars: [], + }, weight: users[1].weight, createdAt: new Date(0), }, ]; await expect( - getLuckyUser({ + luckyUserService.getLuckyUser({ availableUsers: users, eventType: { id: 1, @@ -1399,6 +1489,7 @@ describe("attribute weights and virtual queues", () => { rrResetInterval: RRResetInterval.DAY, rrTimestampBasis: RRTimestampBasis.CREATED_AT, }, + includeNoShowInRRCalculation: false, }, allRRHosts, routingFormResponse, diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index 8ad0b7f439..06437865cb 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -8,12 +8,15 @@ import logger from "@calcom/lib/logger"; import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils"; import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; import { safeStringify } from "@calcom/lib/safeStringify"; -import { BookingRepository } from "@calcom/lib/server/repository/booking"; -import prisma from "@calcom/prisma"; +import type { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; +import type { HostRepository } from "@calcom/lib/server/repository/host"; +import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; +import type { UserRepository } from "@calcom/lib/server/repository/user"; import type { Booking } from "@calcom/prisma/client"; import type { SelectedCalendar } from "@calcom/prisma/client"; import type { AttributeType } from "@calcom/prisma/enums"; -import { BookingStatus, RRTimestampBasis, RRResetInterval } from "@calcom/prisma/enums"; +import { RRTimestampBasis, RRResetInterval } from "@calcom/prisma/enums"; import type { EventBusyDate } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -21,6 +24,7 @@ import { mergeOverlappingRanges } from "../date-ranges"; const log = logger.getSubLogger({ prefix: ["getLuckyUser"] }); const { getAttributesQueryValue } = acrossQueryValueCompatiblity; + type PartialBooking = Pick & { attendees: { email: string | null }[]; }; @@ -88,7 +92,7 @@ interface GetLuckyUserParams { meetingStartTime?: Date; } -// === dayjs.utc().startOf("month").toDate(); +// === Utility Functions kept outside of the class === const startOfMonth = (date: Date = new Date()) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); @@ -148,399 +152,17 @@ export const getIntervalStartDate = ({ return startOfMonth(); }; -// TS helper function. const isNonEmptyArray = (arr: T[]): arr is [T, ...T[]] => arr.length > 0; -function leastRecentlyBookedUser({ - availableUsers, - bookingsOfAvailableUsers, - organizersWithLastCreated, -}: GetLuckyUserParams & { - bookingsOfAvailableUsers: PartialBooking[]; - organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[]; -}) { - const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce( - (keyValuePair: { [userId: number]: Date }, user) => { - keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0); - return keyValuePair; - }, - {} - ); +function isFullDayEvent(date1: Date, date2: Date) { + const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; + const difference = Math.abs(date1.getTime() - date2.getTime()); - const attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.reduce( - (aggregate: { [userId: number]: Date }, booking) => { - availableUsers.forEach((user) => { - if (aggregate[user.id]) return; // Bookings are ordered DESC, so if the reducer aggregate - // contains the user id, it's already got the most recent booking marked. - if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return; - if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; // only consider bookings if they were created after organizer bookings - aggregate[user.id] = booking.createdAt; - }); - return aggregate; - }, - {} - ); - - const userIdAndAtCreatedPair = { - ...organizerIdAndAtCreatedPair, - ...attendeeUserIdAndAtCreatedPair, - }; - - log.info( - "userIdAndAtCreatedPair", - safeStringify({ - organizerIdAndAtCreatedPair, - attendeeUserIdAndAtCreatedPair, - userIdAndAtCreatedPair, - }) - ); - - if (!userIdAndAtCreatedPair) { - throw new Error("Unable to find users by availableUser ids."); // should never happen. - } - - const leastRecentlyBookedUser = availableUsers.sort((a, b) => { - if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1; - else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1; - // if two (or more) dates are identical, we randomize the order - else return 0; - })[0]; - - return leastRecentlyBookedUser; -} - -function getHostsWithCalibration({ - hosts, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - oooData, -}: { - hosts: { userId: number; email: string; createdAt: Date }[]; - allRRHostsBookingsOfInterval: PartialBooking[]; - allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[]; - oooData: OOODataType; -}) { - // Helper function to calculate calibration for a new host - function calculateNewHostCalibration(newHost: { userId: number; createdAt: Date }) { - const existingBookingsBeforeAdded = existingBookings.filter( - (booking) => booking.userId !== newHost.userId && booking.createdAt < newHost.createdAt - ); - const hostsAddedBefore = hosts.filter( - (host) => host.userId !== newHost.userId && host.createdAt < newHost.createdAt - ); - - const calibration = - existingBookingsBeforeAdded.length && hostsAddedBefore.length - ? existingBookingsBeforeAdded.length / hostsAddedBefore.length - : 0; - log.debug( - "calculateNewHostCalibration", - safeStringify({ - newHost, - existingBookingsBeforeAdded: existingBookingsBeforeAdded.length, - hostsAddedBefore: hostsAddedBefore.length, - calibration, - }) - ); - return calibration; - } - - const existingBookings = allRRHostsBookingsOfInterval; - - const oooCalibration = new Map(); - - oooData.forEach(({ userId, oooEntries }) => { - let calibration = 0; - - oooEntries.forEach((oooEntry) => { - const bookingsInTimeframe = existingBookings.filter( - (booking) => - booking.createdAt >= oooEntry.start && - booking.createdAt <= oooEntry.end && - booking.userId !== userId // attendee email check is missing here in case of fixed hosts - ); - - // - 1 because the we need to exclude the current user - calibration += bookingsInTimeframe.length / (hosts.length - 1); - }); - - oooCalibration.set(userId, calibration); - }); - - let newHostsWithCalibration: Map< - number, - { - calibration: number; - userId: number; - createdAt: Date; - } - > = new Map(); - - if (allRRHostsCreatedInInterval.length && existingBookings.length) { - // Calculate calibration for each new host and store in a Map - newHostsWithCalibration = new Map( - allRRHostsCreatedInInterval.map((newHost) => [ - newHost.userId, - { ...newHost, calibration: calculateNewHostCalibration(newHost) }, - ]) - ); - // Map hosts with their respective calibration values - } - - return hosts.map((host) => ({ - ...host, - calibration: - (newHostsWithCalibration.get(host.userId)?.calibration ?? 0) + (oooCalibration.get(host.userId) ?? 0), - })); -} - -function getUsersWithHighestPriority({ - availableUsers, -}: { - availableUsers: T[]; -}) { - const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2)); - const usersWithHighestPriority = availableUsers.filter( - (user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2) - ); - if (!isNonEmptyArray(usersWithHighestPriority)) { - throw new Error("Internal Error: Highest Priority filter should never return length=0."); - } - - log.info( - "getUsersWithHighestPriority", - safeStringify({ - highestPriorityUsers: usersWithHighestPriority.map((user) => user.id), - }) - ); - return usersWithHighestPriority; + if (difference % MILLISECONDS_IN_A_DAY === 0) return true; } type OOODataType = { userId: number; oooEntries: { start: Date; end: Date }[] }[]; -function filterUsersBasedOnWeights< - T extends PartialUser & { - weight?: number | null; - } ->({ - availableUsers, - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHosts, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - attributeWeights, - oooData, -}: GetLuckyUserParams & FetchedData) { - //get all bookings of all other RR hosts that are not available - - const allBookings = bookingsOfAvailableUsersOfInterval.concat(bookingsOfNotAvailableUsersOfInterval); - - const allHostsWithCalibration = getHostsWithCalibration({ - hosts: allRRHosts.map((host) => { - return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt }; - }), - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - oooData, - }); - - // Calculate the total calibration and weight of all round-robin hosts - let totalWeight: number; - - if (attributeWeights && attributeWeights.length > 0) { - totalWeight = attributeWeights.reduce((totalWeight, userWeight) => { - totalWeight += userWeight.weight ?? 100; - return totalWeight; - }, 0); - } else { - totalWeight = allRRHosts.reduce((totalWeight, host) => { - totalWeight += host.weight ?? 100; - return totalWeight; - }, 0); - } - - const totalCalibration = allHostsWithCalibration.reduce((totalCalibration, host) => { - totalCalibration += host.calibration; - return totalCalibration; - }, 0); - - // Calculate booking shortfall for each available user - const usersWithBookingShortfalls = availableUsers.map((user) => { - let userWeight = user.weight ?? 100; - if (attributeWeights) { - userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100; - } - const targetPercentage = userWeight / totalWeight; - const userBookings = bookingsOfAvailableUsersOfInterval.filter( - (booking) => - booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email) - ); - - const targetNumberOfBookings = (allBookings.length + totalCalibration) * targetPercentage; - const userCalibration = allHostsWithCalibration.find((host) => host.userId === user.id)?.calibration ?? 0; - - const bookingShortfall = targetNumberOfBookings - (userBookings.length + userCalibration); - - return { - ...user, - calibration: userCalibration, - weight: userWeight, - targetNumberOfBookings, - bookingShortfall, - numBookings: userBookings.length, - }; - }); - - // Find users with the highest booking shortfall - const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall)); - const usersWithMaxShortfall = usersWithBookingShortfalls.filter( - (user) => user.bookingShortfall === maxShortfall - ); - - // ff more user's were found, find users with highest weights - const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100)); - - const userIdsWithMaxShortfallAndWeight = new Set( - usersWithMaxShortfall - .filter((user) => { - const weight = user.weight ?? 100; - return weight === maxWeight; - }) - .map((user) => user.id) - ); - - const remainingUsersAfterWeightFilter = availableUsers.filter((user) => - userIdsWithMaxShortfallAndWeight.has(user.id) - ); - - log.debug( - "filterUsersBasedOnWeights", - safeStringify({ - userIdsWithMaxShortfallAndWeight: userIdsWithMaxShortfallAndWeight, - usersWithMaxShortfall: usersWithMaxShortfall.map((user) => user.email), - usersWithBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ - calibration: user.calibration, - bookingShortfall: user.bookingShortfall, - email: user.email, - targetNumberOfBookings: user.targetNumberOfBookings, - weight: user.weight, - numBookings: user.numBookings, - })), - remainingUsersAfterWeightFilter: remainingUsersAfterWeightFilter.map((user) => user.email), - }) - ); - - if (!isNonEmptyArray(remainingUsersAfterWeightFilter)) { - throw new Error("Internal Error: Weight filter should never return length=0."); - } - return { - remainingUsersAfterWeightFilter, - usersAndTheirBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ - id: user.id, - calibration: user.calibration, - bookingShortfall: user.bookingShortfall, - weight: user.weight, - })), - }; -} - -async function getCalendarBusyTimesOfInterval( - usersWithCredentials: { - id: number; - email: string; - credentials: CredentialForCalendarService[]; - userLevelSelectedCalendars: SelectedCalendar[]; - }[], - interval: RRResetInterval, - rrTimestampBasis: RRTimestampBasis, - meetingStartTime?: Date -): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> { - const usersBusyTimesQuery = await Promise.all( - usersWithCredentials.map((user) => - getBusyCalendarTimes( - user.credentials, - getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(), - getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(), - user.userLevelSelectedCalendars, - true, - true - ) - ) - ); - - return usersBusyTimesQuery.reduce((usersBusyTime, userBusyTimeQuery, index) => { - if (userBusyTimeQuery.success) { - usersBusyTime.push({ - userId: usersWithCredentials[index].id, - busyTimes: userBusyTimeQuery.data, - }); - } - return usersBusyTime; - }, [] as { userId: number; busyTimes: Awaited>["data"] }[]); -} - -async function getBookingsOfInterval({ - eventTypeId, - users, - virtualQueuesData, - interval, - includeNoShowInRRCalculation, - rrTimestampBasis, - meetingStartTime, -}: { - eventTypeId: number; - users: { id: number; email: string }[]; - virtualQueuesData: VirtualQueuesDataType | null; - interval: RRResetInterval; - includeNoShowInRRCalculation: boolean; - rrTimestampBasis: RRTimestampBasis; - meetingStartTime?: Date; -}) { - const bookingRepo = new BookingRepository(prisma); - return await bookingRepo.getAllBookingsForRoundRobin({ - eventTypeId: eventTypeId, - users, - startDate: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }), - endDate: getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }), - virtualQueuesData, - includeNoShowInRRCalculation, - rrTimestampBasis, - }); -} - -export async function getLuckyUser< - T extends PartialUser & { - priority?: number | null; - weight?: number | null; - } ->(getLuckyUserParams: GetLuckyUserParams) { - const { - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - organizersWithLastCreated, - attributeWeights, - virtualQueuesData, - oooData, - } = await fetchAllDataNeededForCalculations(getLuckyUserParams); - - const { luckyUser } = getLuckyUser_requiresDataToBePreFetched({ - ...getLuckyUserParams, - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - organizersWithLastCreated, - attributeWeights, - virtualQueuesData, - oooData, - }); - - return luckyUser; -} - type FetchedData = { bookingsOfNotAvailableUsersOfInterval: PartialBooking[]; bookingsOfAvailableUsersOfInterval: PartialBooking[]; @@ -557,670 +179,957 @@ type FetchedData = { oooData: OOODataType; }; -export function getLuckyUser_requiresDataToBePreFetched< - T extends PartialUser & { - priority?: number | null; - weight?: number | null; - } ->({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams & FetchedData) { - const { - eventType, - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - organizersWithLastCreated, - oooData, - } = getLuckyUserParams; - - // there is only one user - if (availableUsers.length === 1) { - return { luckyUser: availableUsers[0], usersAndTheirBookingShortfalls: [] }; - } - - let usersAndTheirBookingShortfalls: { - id: number; - bookingShortfall: number; - calibration: number; - weight: number; - }[] = []; - if (eventType.isRRWeightsEnabled) { - const { - remainingUsersAfterWeightFilter, - usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls, - } = filterUsersBasedOnWeights({ - ...getLuckyUserParams, - availableUsers, - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - oooData, - }); - availableUsers = remainingUsersAfterWeightFilter; - usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; - } - - const highestPriorityUsers = getUsersWithHighestPriority({ availableUsers }); - // No need to round-robin through the only user, return early also. - if (highestPriorityUsers.length === 1) { - return { - luckyUser: highestPriorityUsers[0], - usersAndTheirBookingShortfalls, - }; - } - // TS is happy. - return { - luckyUser: leastRecentlyBookedUser({ - ...getLuckyUserParams, - availableUsers: highestPriorityUsers, - bookingsOfAvailableUsers: bookingsOfAvailableUsersOfInterval, - organizersWithLastCreated, - }), - usersAndTheirBookingShortfalls, - }; -} - -function isFullDayEvent(date1: Date, date2: Date) { - const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; - const difference = Math.abs(date1.getTime() - date2.getTime()); - - if (difference % MILLISECONDS_IN_A_DAY === 0) return true; -} - -async function fetchAllDataNeededForCalculations< - T extends PartialUser & { - priority?: number | null; - weight?: number | null; - } ->(getLuckyUserParams: GetLuckyUserParams) { - const startTime = performance.now(); - - const { availableUsers, allRRHosts, eventType, meetingStartTime } = getLuckyUserParams; - const notAvailableHosts = (function getNotAvailableHosts() { - const availableUserIds = new Set(availableUsers.map((user) => user.id)); - return allRRHosts.reduce( - ( - acc: { - id: number; - email: string; - }[], - host - ) => { - if (!availableUserIds.has(host.user.id)) { - acc.push({ - id: host.user.id, - email: host.user.email, - }); - } - return acc; - }, - [] - ); - })(); - - const { attributeWeights, virtualQueuesData } = await prepareQueuesAndAttributesData(getLuckyUserParams); - - const interval = - eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrResetInterval - ? getLuckyUserParams.eventType.team?.rrResetInterval - : RRResetInterval.MONTH; - - const rrTimestampBasis = - eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrTimestampBasis - ? getLuckyUserParams.eventType.team.rrTimestampBasis - : RRTimestampBasis.CREATED_AT; - - const [ - userBusyTimesOfInterval, - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - organizersWithLastCreated, - ] = await Promise.all([ - getCalendarBusyTimesOfInterval( - allRRHosts.map((host) => host.user), - interval, - rrTimestampBasis, - meetingStartTime - ), - getBookingsOfInterval({ - eventTypeId: eventType.id, - users: availableUsers.map((user) => { - return { id: user.id, email: user.email }; - }), - virtualQueuesData: virtualQueuesData ?? null, - interval, - includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, - rrTimestampBasis, - meetingStartTime, - }), - - getBookingsOfInterval({ - eventTypeId: eventType.id, - users: notAvailableHosts, - virtualQueuesData: virtualQueuesData ?? null, - interval, - includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, - rrTimestampBasis, - meetingStartTime, - }), - - getBookingsOfInterval({ - eventTypeId: eventType.id, - users: allRRHosts.map((host) => { - return { id: host.user.id, email: host.user.email }; - }), - virtualQueuesData: virtualQueuesData ?? null, - interval, - includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, - rrTimestampBasis, - meetingStartTime, - }), - - prisma.host.findMany({ - where: { - userId: { - in: allRRHosts.map((host) => host.user.id), - }, - eventTypeId: eventType.id, - isFixed: false, - createdAt: { - gte: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }), - }, - }, - }), - - prisma.user.findMany({ - where: { - id: { - in: availableUsers.map((user) => user.id), - }, - }, - select: { - id: true, - bookings: { - select: { - createdAt: true, - }, - where: { - eventTypeId: eventType.id, - status: BookingStatus.ACCEPTED, - attendees: { - some: { - noShow: false, - }, - }, - // not:true won't match null, thus we need to do an OR with null case separately(for bookings that might have null value for `noShowHost` as earlier it didn't have default false) - // https://github.com/calcom/cal.com/pull/15323#discussion_r1687728207 - OR: [ - { - noShowHost: false, - }, - { - noShowHost: null, - }, - ], - }, - orderBy: { - createdAt: "desc", - }, - take: 1, - }, - }, - }), - ]); - - const userFullDayBusyTimes = new Map(); - - userBusyTimesOfInterval.forEach((userBusyTime) => { - const fullDayBusyTimes = userBusyTime.busyTimes - .filter((busyTime) => { - //timeZone is always defined when calling getBusyCalendarTimes with includeTimeZone true - if (!busyTime.timeZone) return false; - - // make sure start date and end date is converted to 00:00 for full day busy events - const timezoneOffset = dayjs(busyTime.start).tz(busyTime.timeZone).utcOffset() * 60000; - let start = new Date(new Date(busyTime.start).getTime() + timezoneOffset); - const end = new Date(new Date(busyTime.end).getTime() + timezoneOffset); - - // needed for full day busy events that started the month before - const earliestStartTime = new Date(Date.UTC(new Date().getFullYear(), new Date().getMonth(), 1)); - if (start < earliestStartTime) start = earliestStartTime; - - return end.getTime() < new Date().getTime() && isFullDayEvent(start, end); - }) - .map((busyTime) => ({ start: new Date(busyTime.start), end: new Date(busyTime.end) })); - - userFullDayBusyTimes.set(userBusyTime.userId, fullDayBusyTimes); - }); - - // get cal.com OOO data - const oooEntries = await prisma.outOfOfficeEntry.findMany({ - where: { - userId: { - in: allRRHosts.map((host) => host.user.id), - }, - end: { - lte: getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }), - gte: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }), - }, - }, - select: { - start: true, - end: true, - userId: true, - }, - }); - - const oooEntriesGroupedByUserId = new Map(); - - oooEntries.forEach((entry) => { - if (!oooEntriesGroupedByUserId.has(entry.userId)) { - oooEntriesGroupedByUserId.set(entry.userId, []); - } - oooEntriesGroupedByUserId.get(entry.userId)!.push({ start: entry.start, end: entry.end }); - }); - - const oooData: { userId: number; oooEntries: { start: Date; end: Date }[] }[] = []; - - userFullDayBusyTimes.forEach((fullDayBusyTimes, userId) => { - const oooEntriesForUser = oooEntriesGroupedByUserId.get(userId) || []; - const combinedEntries = [...oooEntriesForUser, ...fullDayBusyTimes]; - const oooEntries = mergeOverlappingRanges(combinedEntries); - - oooData.push({ - userId, - oooEntries, - }); - }); - - const endTime = performance.now(); - log.info(`fetchAllDataNeededForCalculations took ${endTime - startTime}ms`); - - log.debug( - "fetchAllDataNeededForCalculations", - safeStringify({ - bookingsOfAvailableUsersOfInterval: bookingsOfAvailableUsersOfInterval.length, - bookingsOfNotAvailableUsersOfInterval: bookingsOfNotAvailableUsersOfInterval.length, - allRRHostsBookingsOfInterval: allRRHostsBookingsOfInterval.length, - allRRHostsCreatedInInterval: allRRHostsCreatedInInterval.length, - virtualQueuesData, - attributeWeights, - oooData, - }) - ); - - return { - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, - allRRHostsBookingsOfInterval, - allRRHostsCreatedInInterval, - organizersWithLastCreated, - attributeWeights, - virtualQueuesData, - oooData, - }; -} - type AvailableUserBase = PartialUser & { priority: number | null; weight: number | null; }; -export async function getOrderedListOfLuckyUsers( - getLuckyUserParams: GetLuckyUserParams -) { - const { availableUsers, eventType } = getLuckyUserParams; +export interface ILuckyUserService { + bookingRepository: BookingRepository; + hostRepository: HostRepository; + oooRepository: PrismaOOORepository; + userRepository: UserRepository; + attributeRepository: PrismaAttributeRepository; +} - const { - bookingsOfAvailableUsersOfInterval, - bookingsOfNotAvailableUsersOfInterval, +export class LuckyUserService implements ILuckyUserService { + public bookingRepository: BookingRepository; + public hostRepository: HostRepository; + public oooRepository: PrismaOOORepository; + public userRepository: UserRepository; + public attributeRepository: PrismaAttributeRepository; + + constructor(deps: ILuckyUserService) { + this.bookingRepository = deps.bookingRepository; + this.hostRepository = deps.hostRepository; + this.oooRepository = deps.oooRepository; + this.userRepository = deps.userRepository; + this.attributeRepository = deps.attributeRepository; + } + + private leastRecentlyBookedUser({ + availableUsers, + bookingsOfAvailableUsers, + organizersWithLastCreated, + }: GetLuckyUserParams & { + bookingsOfAvailableUsers: PartialBooking[]; + organizersWithLastCreated: { id: number; bookings: { createdAt: Date }[] }[]; + }) { + const organizerIdAndAtCreatedPair = organizersWithLastCreated.reduce( + (keyValuePair: { [userId: number]: Date }, user) => { + keyValuePair[user.id] = user.bookings[0]?.createdAt || new Date(0); + return keyValuePair; + }, + {} + ); + + const attendeeUserIdAndAtCreatedPair = bookingsOfAvailableUsers.reduce( + (aggregate: { [userId: number]: Date }, booking) => { + availableUsers.forEach((user) => { + if (aggregate[user.id]) return; + if (!booking.attendees.map((attendee) => attendee.email).includes(user.email)) return; + if (organizerIdAndAtCreatedPair[user.id] > booking.createdAt) return; + aggregate[user.id] = booking.createdAt; + }); + return aggregate; + }, + {} + ); + + const userIdAndAtCreatedPair = { + ...organizerIdAndAtCreatedPair, + ...attendeeUserIdAndAtCreatedPair, + }; + + log.info( + "userIdAndAtCreatedPair", + safeStringify({ + organizerIdAndAtCreatedPair, + attendeeUserIdAndAtCreatedPair, + userIdAndAtCreatedPair, + }) + ); + + if (!userIdAndAtCreatedPair) { + throw new Error("Unable to find users by availableUser ids."); + } + + const leastRecentlyBookedUser = availableUsers.sort((a, b) => { + if (userIdAndAtCreatedPair[a.id] > userIdAndAtCreatedPair[b.id]) return 1; + else if (userIdAndAtCreatedPair[a.id] < userIdAndAtCreatedPair[b.id]) return -1; + else return 0; + })[0]; + + return leastRecentlyBookedUser; + } + + private getHostsWithCalibration({ + hosts, allRRHostsBookingsOfInterval, allRRHostsCreatedInInterval, - organizersWithLastCreated, - attributeWeights, - virtualQueuesData, oooData, - } = await fetchAllDataNeededForCalculations(getLuckyUserParams); + }: { + hosts: { userId: number; email: string; createdAt: Date }[]; + allRRHostsBookingsOfInterval: PartialBooking[]; + allRRHostsCreatedInInterval: { userId: number; createdAt: Date }[]; + oooData: OOODataType; + }) { + function calculateNewHostCalibration(newHost: { userId: number; createdAt: Date }) { + const existingBookingsBeforeAdded = existingBookings.filter( + (booking) => booking.userId !== newHost.userId && booking.createdAt < newHost.createdAt + ); + const hostsAddedBefore = hosts.filter( + (host) => host.userId !== newHost.userId && host.createdAt < newHost.createdAt + ); - log.info( - "getOrderedListOfLuckyUsers", - safeStringify({ - availableUsers: availableUsers.map((user) => { - return { id: user.id, email: user.email, priority: user.priority, weight: user.weight }; + const calibration = + existingBookingsBeforeAdded.length && hostsAddedBefore.length + ? existingBookingsBeforeAdded.length / hostsAddedBefore.length + : 0; + log.debug( + "calculateNewHostCalibration", + safeStringify({ + newHost, + existingBookingsBeforeAdded: existingBookingsBeforeAdded.length, + hostsAddedBefore: hostsAddedBefore.length, + calibration, + }) + ); + return calibration; + } + + const existingBookings = allRRHostsBookingsOfInterval; + + const oooCalibration = new Map(); + + oooData.forEach(({ userId, oooEntries }) => { + let calibration = 0; + + oooEntries.forEach((oooEntry) => { + const bookingsInTimeframe = existingBookings.filter( + (booking) => + booking.createdAt >= oooEntry.start && + booking.createdAt <= oooEntry.end && + booking.userId !== userId + ); + calibration += bookingsInTimeframe.length / (hosts.length - 1); + }); + + oooCalibration.set(userId, calibration); + }); + + let newHostsWithCalibration: Map< + number, + { + calibration: number; + userId: number; + createdAt: Date; + } + > = new Map(); + + if (allRRHostsCreatedInInterval.length && existingBookings.length) { + newHostsWithCalibration = new Map( + allRRHostsCreatedInInterval.map((newHost) => [ + newHost.userId, + { ...newHost, calibration: calculateNewHostCalibration(newHost) }, + ]) + ); + } + + return hosts.map((host) => ({ + ...host, + calibration: + (newHostsWithCalibration.get(host.userId)?.calibration ?? 0) + (oooCalibration.get(host.userId) ?? 0), + })); + } + + private getUsersWithHighestPriority({ + availableUsers, + }: { + availableUsers: T[]; + }) { + const highestPriority = Math.max(...availableUsers.map((user) => user.priority ?? 2)); + const usersWithHighestPriority = availableUsers.filter( + (user) => user.priority === highestPriority || (user.priority == null && highestPriority === 2) + ); + if (!isNonEmptyArray(usersWithHighestPriority)) { + throw new Error("Internal Error: Highest Priority filter should never return length=0."); + } + + log.info( + "getUsersWithHighestPriority", + safeStringify({ + highestPriorityUsers: usersWithHighestPriority.map((user) => user.id), + }) + ); + return usersWithHighestPriority; + } + + private filterUsersBasedOnWeights< + T extends PartialUser & { + weight?: number | null; + } + >({ + availableUsers, + bookingsOfAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHosts, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + attributeWeights, + oooData, + }: GetLuckyUserParams & FetchedData) { + const allBookings = bookingsOfAvailableUsersOfInterval.concat(bookingsOfNotAvailableUsersOfInterval); + + const allHostsWithCalibration = this.getHostsWithCalibration({ + hosts: allRRHosts.map((host) => { + return { email: host.user.email, userId: host.user.id, createdAt: host.createdAt }; }), + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + oooData, + }); + + let totalWeight: number; + + if (attributeWeights && attributeWeights.length > 0) { + totalWeight = attributeWeights.reduce((totalWeight, userWeight) => { + totalWeight += userWeight.weight ?? 100; + return totalWeight; + }, 0); + } else { + totalWeight = allRRHosts.reduce((totalWeight, host) => { + totalWeight += host.weight ?? 100; + return totalWeight; + }, 0); + } + + const totalCalibration = allHostsWithCalibration.reduce((totalCalibration, host) => { + totalCalibration += host.calibration; + return totalCalibration; + }, 0); + + const usersWithBookingShortfalls = availableUsers.map((user) => { + let userWeight = user.weight ?? 100; + if (attributeWeights) { + userWeight = attributeWeights.find((userWeight) => userWeight.userId === user.id)?.weight ?? 100; + } + const targetPercentage = userWeight / totalWeight; + const userBookings = bookingsOfAvailableUsersOfInterval.filter( + (booking) => + booking.userId === user.id || booking.attendees.some((attendee) => attendee.email === user.email) + ); + + const targetNumberOfBookings = (allBookings.length + totalCalibration) * targetPercentage; + const userCalibration = + allHostsWithCalibration.find((host) => host.userId === user.id)?.calibration ?? 0; + + const bookingShortfall = targetNumberOfBookings - (userBookings.length + userCalibration); + + return { + ...user, + calibration: userCalibration, + weight: userWeight, + targetNumberOfBookings, + bookingShortfall, + numBookings: userBookings.length, + }; + }); + + const maxShortfall = Math.max(...usersWithBookingShortfalls.map((user) => user.bookingShortfall)); + const usersWithMaxShortfall = usersWithBookingShortfalls.filter( + (user) => user.bookingShortfall === maxShortfall + ); + + const maxWeight = Math.max(...usersWithMaxShortfall.map((user) => user.weight ?? 100)); + + const userIdsWithMaxShortfallAndWeight = new Set( + usersWithMaxShortfall + .filter((user) => { + const weight = user.weight ?? 100; + return weight === maxWeight; + }) + .map((user) => user.id) + ); + + const remainingUsersAfterWeightFilter = availableUsers.filter((user) => + userIdsWithMaxShortfallAndWeight.has(user.id) + ); + + log.debug( + "filterUsersBasedOnWeights", + safeStringify({ + userIdsWithMaxShortfallAndWeight: userIdsWithMaxShortfallAndWeight, + usersWithMaxShortfall: usersWithMaxShortfall.map((user) => user.email), + usersWithBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ + calibration: user.calibration, + bookingShortfall: user.bookingShortfall, + email: user.email, + targetNumberOfBookings: user.targetNumberOfBookings, + weight: user.weight, + numBookings: user.numBookings, + })), + remainingUsersAfterWeightFilter: remainingUsersAfterWeightFilter.map((user) => user.email), + }) + ); + + if (!isNonEmptyArray(remainingUsersAfterWeightFilter)) { + throw new Error("Internal Error: Weight filter should never return length=0."); + } + return { + remainingUsersAfterWeightFilter, + usersAndTheirBookingShortfalls: usersWithBookingShortfalls.map((user) => ({ + id: user.id, + calibration: user.calibration, + bookingShortfall: user.bookingShortfall, + weight: user.weight, + })), + }; + } + + private getAverageAttributeWeights< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } + >( + allRRHosts: GetLuckyUserParams["allRRHosts"], + attributesQueryValueChild: Record< + string, + { + type?: string | undefined; + properties?: + | { + field?: any; + operator?: any; + value?: any; + valueSrc?: any; + } + | undefined; + } + >, + attributeWithWeights: AttributeWithWeights + ) { + let averageWeightsHosts: { userId: number; weight: number }[] = []; + + const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({ + field: child.properties?.field, + value: child.properties?.value, + })); + + fieldValueArray.map((obj) => { + const attributeId = obj.field; + const allRRHostsWeights = new Map(); + + if (attributeId === attributeWithWeights.id) { + obj.value.forEach((arrayobj: string[]) => { + arrayobj.forEach((attributeOption: string) => { + const attributeOptionWithUsers = attributeWithWeights.options.find( + (option) => option.value.toLowerCase() === attributeOption.toLowerCase() + ); + + allRRHosts.forEach((rrHost) => { + const assignedUser = attributeOptionWithUsers?.assignedUsers.find( + (assignedUser) => rrHost.user.id === assignedUser.member.userId + ); + + if (allRRHostsWeights.has(rrHost.user.id)) { + allRRHostsWeights.get(rrHost.user.id)?.push(assignedUser?.weight ?? rrHost.weight ?? 100); + } else { + allRRHostsWeights.set(rrHost.user.id, [assignedUser?.weight ?? rrHost.weight ?? 100]); + } + }); + }); + }); + averageWeightsHosts = Array.from(allRRHostsWeights.entries()).map(([userId, weights]) => { + const totalWeight = weights.reduce((acc, weight) => acc + weight, 0); + const averageWeight = totalWeight / weights.length; + + return { + userId, + weight: averageWeight, + }; + }); + } + }); + log.debug( + "getAverageAttributeWeights", + safeStringify({ allRRHosts, attributesQueryValueChild, attributeWithWeights, averageWeightsHosts }) + ); + + return averageWeightsHosts; + } + + private getAttributesForVirtualQueues( + response: Record>, + attributesQueryValueChild: Record< + string, + { + type?: string | undefined; + properties?: + | { + field?: any; + operator?: any; + value?: any; + valueSrc?: any; + } + | undefined; + } + >, + attributeWithWeights: { id: string } + ) { + let selectionOptions: Pick["fieldOptionData"] | undefined; + + const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({ + field: child.properties?.field, + value: child.properties?.value, + })); + + fieldValueArray.some((obj) => { + const attributeId = obj.field; + + if (attributeId === attributeWithWeights.id) { + obj.value.some((arrayobj: string[]) => { + arrayobj.some((attributeOptionId: string) => { + const content = attributeOptionId.slice(1, -1); + + const routingFormFieldId = content.includes("field:") ? content.split("field:")[1] : null; + + if (routingFormFieldId) { + const fieldResponse = response[routingFormFieldId]; + selectionOptions = { fieldId: routingFormFieldId, selectedOptionIds: fieldResponse.value }; + return true; + } + }); + }); + } + }); + return selectionOptions; + } + + private async getQueueAndAttributeWeightData( + allRRHosts: GetLuckyUserParams["allRRHosts"], + routingFormResponse: RoutingFormResponse, + attributeWithWeights: AttributeWithWeights + ) { + let averageWeightsHosts: { userId: number; weight: number }[] = []; + const chosenRouteId = routingFormResponse?.chosenRouteId ?? undefined; + + if (!chosenRouteId) return; + + let fieldOptionData: { fieldId: string; selectedOptionIds: string | number | string[] } | undefined; + const routingForm = routingFormResponse?.form; + + if (routingForm && routingFormResponse) { + const response = routingFormResponse.response as FormResponse; + const routes = zodRoutes.parse(routingForm.routes); + const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId); + + if (chosenRoute && "attributesQueryValue" in chosenRoute) { + const parsedAttributesQueryValue = raqbQueryValueSchema.parse(chosenRoute.attributesQueryValue); + + const attributesQueryValueWithLabel = getAttributesQueryValue({ + attributesQueryValue: chosenRoute.attributesQueryValue, + attributes: [attributeWithWeights], + dynamicFieldValueOperands: { + fields: (routingFormResponse.form.fields as Fields) || [], + response, + }, + }); + + const parsedAttributesQueryValueWithLabel = raqbQueryValueSchema.parse(attributesQueryValueWithLabel); + + if (parsedAttributesQueryValueWithLabel && parsedAttributesQueryValueWithLabel.children1) { + averageWeightsHosts = this.getAverageAttributeWeights( + allRRHosts, + parsedAttributesQueryValueWithLabel.children1, + attributeWithWeights + ); + } + + if (parsedAttributesQueryValue && parsedAttributesQueryValue.children1) { + fieldOptionData = this.getAttributesForVirtualQueues( + response, + parsedAttributesQueryValue.children1, + attributeWithWeights + ); + } + } + } + + if (fieldOptionData) { + return { averageWeightsHosts, virtualQueuesData: { chosenRouteId, fieldOptionData } }; + } + + return; + } + + private async getCalendarBusyTimesOfInterval( + usersWithCredentials: { + id: number; + email: string; + credentials: CredentialForCalendarService[]; + userLevelSelectedCalendars: SelectedCalendar[]; + }[], + interval: RRResetInterval, + rrTimestampBasis: RRTimestampBasis, + meetingStartTime?: Date + ): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> { + const usersBusyTimesQuery = await Promise.all( + usersWithCredentials.map((user) => + getBusyCalendarTimes( + user.credentials, + getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(), + getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }).toISOString(), + user.userLevelSelectedCalendars, + true, + true + ) + ) + ); + + return usersBusyTimesQuery.reduce((usersBusyTime, userBusyTimeQuery, index) => { + if (userBusyTimeQuery.success) { + usersBusyTime.push({ + userId: usersWithCredentials[index].id, + busyTimes: userBusyTimeQuery.data, + }); + } + return usersBusyTime; + }, [] as { userId: number; busyTimes: Awaited>["data"] }[]); + } + + private async getBookingsOfInterval({ + eventTypeId, + users, + virtualQueuesData, + interval, + includeNoShowInRRCalculation, + rrTimestampBasis, + meetingStartTime, + }: { + eventTypeId: number; + users: { id: number; email: string }[]; + virtualQueuesData: VirtualQueuesDataType | null; + interval: RRResetInterval; + includeNoShowInRRCalculation: boolean; + rrTimestampBasis: RRTimestampBasis; + meetingStartTime?: Date; + }) { + return await this.bookingRepository.getAllBookingsForRoundRobin({ + eventTypeId: eventTypeId, + users, + startDate: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }), + endDate: getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }), + virtualQueuesData, + includeNoShowInRRCalculation, + rrTimestampBasis, + }); + } + + private async fetchAllDataNeededForCalculations< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } + >(getLuckyUserParams: GetLuckyUserParams): Promise { + const startTime = performance.now(); + + const { availableUsers, allRRHosts, eventType, meetingStartTime } = getLuckyUserParams; + const notAvailableHosts = (function getNotAvailableHosts() { + const availableUserIds = new Set(availableUsers.map((user) => user.id)); + return allRRHosts.reduce( + ( + acc: { + id: number; + email: string; + }[], + host + ) => { + if (!availableUserIds.has(host.user.id)) { + acc.push({ + id: host.user.id, + email: host.user.email, + }); + } + return acc; + }, + [] + ); + })(); + + const { attributeWeights, virtualQueuesData } = await this.prepareQueuesAndAttributesData( + getLuckyUserParams + ); + + const interval = + eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrResetInterval + ? getLuckyUserParams.eventType.team?.rrResetInterval + : RRResetInterval.MONTH; + + const rrTimestampBasis = + eventType.isRRWeightsEnabled && getLuckyUserParams.eventType.team?.rrTimestampBasis + ? getLuckyUserParams.eventType.team.rrTimestampBasis + : RRTimestampBasis.CREATED_AT; + + const intervalStartDate = getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }); + const intervalEndDate = getIntervalEndDate({ interval, rrTimestampBasis, meetingStartTime }); + + const [ + userBusyTimesOfInterval, bookingsOfAvailableUsersOfInterval, bookingsOfNotAvailableUsersOfInterval, allRRHostsBookingsOfInterval, allRRHostsCreatedInInterval, organizersWithLastCreated, - }) - ); + ] = await Promise.all([ + this.getCalendarBusyTimesOfInterval( + allRRHosts.map((host) => host.user), + interval, + rrTimestampBasis, + meetingStartTime + ), + this.getBookingsOfInterval({ + eventTypeId: eventType.id, + users: availableUsers.map((user) => { + return { id: user.id, email: user.email }; + }), + virtualQueuesData: virtualQueuesData ?? null, + interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + rrTimestampBasis, + meetingStartTime, + }), + this.getBookingsOfInterval({ + eventTypeId: eventType.id, + users: notAvailableHosts, + virtualQueuesData: virtualQueuesData ?? null, + interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + rrTimestampBasis, + meetingStartTime, + }), + this.getBookingsOfInterval({ + eventTypeId: eventType.id, + users: allRRHosts.map((host) => { + return { id: host.user.id, email: host.user.email }; + }), + virtualQueuesData: virtualQueuesData ?? null, + interval, + includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation, + rrTimestampBasis, + meetingStartTime, + }), + this.hostRepository.findHostsCreatedInInterval({ + eventTypeId: eventType.id, + userIds: allRRHosts.map((host) => host.user.id), + startDate: intervalStartDate, + }), + this.userRepository.findUsersWithLastBooking({ + userIds: availableUsers.map((user) => user.id), + eventTypeId: eventType.id, + }), + ]); - let remainingAvailableUsers = [...availableUsers]; - let bookingsOfRemainingAvailableUsersOfInterval = [...bookingsOfAvailableUsersOfInterval]; - const orderedUsersSet = new Set(); - const perUserBookingsCount: Record = {}; + const userFullDayBusyTimes = new Map(); - const startTime = performance.now(); - let usersAndTheirBookingShortfalls: { - id: number; - bookingShortfall: number; - calibration: number; - weight: number; - }[] = []; - // Keep getting lucky users until none remain - while (remainingAvailableUsers.length > 0) { - const { luckyUser, usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls } = - getLuckyUser_requiresDataToBePreFetched({ + userBusyTimesOfInterval.forEach((userBusyTime) => { + const fullDayBusyTimes = userBusyTime.busyTimes + .filter((busyTime) => { + if (!busyTime.timeZone) return false; + const timezoneOffset = dayjs(busyTime.start).tz(busyTime.timeZone).utcOffset() * 60000; + let start = new Date(new Date(busyTime.start).getTime() + timezoneOffset); + const end = new Date(new Date(busyTime.end).getTime() + timezoneOffset); + + const earliestStartTime = new Date(Date.UTC(new Date().getFullYear(), new Date().getMonth(), 1)); + if (start < earliestStartTime) start = earliestStartTime; + + return end.getTime() < new Date().getTime() && isFullDayEvent(start, end); + }) + .map((busyTime) => ({ start: new Date(busyTime.start), end: new Date(busyTime.end) })); + + userFullDayBusyTimes.set(userBusyTime.userId, fullDayBusyTimes); + }); + + const oooEntries = await this.oooRepository.findOOOEntriesInInterval({ + userIds: allRRHosts.map((host) => host.user.id), + startDate: intervalStartDate, + endDate: intervalEndDate, + }); + + const oooEntriesGroupedByUserId = new Map(); + + oooEntries.forEach((entry) => { + if (!oooEntriesGroupedByUserId.has(entry.userId)) { + oooEntriesGroupedByUserId.set(entry.userId, []); + } + oooEntriesGroupedByUserId.get(entry.userId)!.push({ start: entry.start, end: entry.end }); + }); + + const oooData: { userId: number; oooEntries: { start: Date; end: Date }[] }[] = []; + + userFullDayBusyTimes.forEach((fullDayBusyTimes, userId) => { + const oooEntriesForUser = oooEntriesGroupedByUserId.get(userId) || []; + const combinedEntries = [...oooEntriesForUser, ...fullDayBusyTimes]; + const oooEntries = mergeOverlappingRanges(combinedEntries); + + oooData.push({ + userId, + oooEntries, + }); + }); + + const endTime = performance.now(); + log.info(`fetchAllDataNeededForCalculations took ${endTime - startTime}ms`); + + log.debug( + "fetchAllDataNeededForCalculations", + safeStringify({ + bookingsOfAvailableUsersOfInterval: bookingsOfAvailableUsersOfInterval.length, + bookingsOfNotAvailableUsersOfInterval: bookingsOfNotAvailableUsersOfInterval.length, + allRRHostsBookingsOfInterval: allRRHostsBookingsOfInterval.length, + allRRHostsCreatedInInterval: allRRHostsCreatedInInterval.length, + virtualQueuesData, + attributeWeights, + oooData, + }) + ); + + return { + bookingsOfAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + oooData, + }; + } + + // === Public methods exposed by the service === + public async getLuckyUser< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } + >(getLuckyUserParams: GetLuckyUserParams) { + const fetchedData = await this.fetchAllDataNeededForCalculations(getLuckyUserParams); + + const { luckyUser } = this.getLuckyUser_requiresDataToBePreFetched({ + ...getLuckyUserParams, + ...fetchedData, + }); + + return luckyUser; + } + + public getLuckyUser_requiresDataToBePreFetched< + T extends PartialUser & { + priority?: number | null; + weight?: number | null; + } + >({ availableUsers, ...getLuckyUserParams }: GetLuckyUserParams & FetchedData) { + const { + eventType, + bookingsOfAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + organizersWithLastCreated, + oooData, + } = getLuckyUserParams; + + if (availableUsers.length === 1) { + return { luckyUser: availableUsers[0], usersAndTheirBookingShortfalls: [] }; + } + + let usersAndTheirBookingShortfalls: { + id: number; + bookingShortfall: number; + calibration: number; + weight: number; + }[] = []; + if (eventType.isRRWeightsEnabled) { + const { + remainingUsersAfterWeightFilter, + usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls, + } = this.filterUsersBasedOnWeights({ ...getLuckyUserParams, - eventType, - availableUsers: remainingAvailableUsers as [AvailableUser, ...AvailableUser[]], - bookingsOfAvailableUsersOfInterval: bookingsOfRemainingAvailableUsersOfInterval, + availableUsers, + bookingsOfAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + oooData, + }); + availableUsers = remainingUsersAfterWeightFilter; + usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; + } + + const highestPriorityUsers = this.getUsersWithHighestPriority({ availableUsers }); + if (highestPriorityUsers.length === 1) { + return { + luckyUser: highestPriorityUsers[0], + usersAndTheirBookingShortfalls, + }; + } + + return { + luckyUser: this.leastRecentlyBookedUser({ + ...getLuckyUserParams, + availableUsers: highestPriorityUsers, + bookingsOfAvailableUsers: bookingsOfAvailableUsersOfInterval, + organizersWithLastCreated, + }), + usersAndTheirBookingShortfalls, + }; + } + + public async prepareQueuesAndAttributesData({ + eventType, + routingFormResponse, + allRRHosts, + }: Omit, "availableUsers">) { + let attributeWeights; + let virtualQueuesData; + const organizationId = eventType.team?.parentId; + log.debug("prepareQueuesAndAttributesData", safeStringify({ routingFormResponse, organizationId })); + if (routingFormResponse && organizationId) { + const routingForm = routingFormResponse?.form; + const routes = zodRoutes.parse(routingForm.routes); + const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId); + + if (chosenRoute && "attributeIdForWeights" in chosenRoute) { + const attributeIdForWeights = chosenRoute.attributeIdForWeights; + + if (attributeIdForWeights) { + const attributeWithEnabledWeights = await this.attributeRepository.findUniqueWithWeights({ + teamId: organizationId, + attributeId: attributeIdForWeights, + isWeightsEnabled: true, + }); + + if (attributeWithEnabledWeights) { + const queueAndAtributeWeightData = await this.getQueueAndAttributeWeightData( + allRRHosts, + routingFormResponse, + attributeWithEnabledWeights as AttributeWithWeights + ); + if ( + queueAndAtributeWeightData?.averageWeightsHosts && + queueAndAtributeWeightData?.virtualQueuesData + ) { + attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts; + virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData; + } + } + } + } + } + return { attributeWeights, virtualQueuesData }; + } + + public async getOrderedListOfLuckyUsers( + getLuckyUserParams: GetLuckyUserParams + ) { + const { availableUsers, eventType } = getLuckyUserParams; + + const { + bookingsOfAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + oooData, + } = await this.fetchAllDataNeededForCalculations(getLuckyUserParams); + + log.info( + "getOrderedListOfLuckyUsers", + safeStringify({ + availableUsers: availableUsers.map((user) => { + return { id: user.id, email: user.email, priority: user.priority, weight: user.weight }; + }), + bookingsOfAvailableUsersOfInterval, bookingsOfNotAvailableUsersOfInterval, allRRHostsBookingsOfInterval, allRRHostsCreatedInInterval, organizersWithLastCreated, - attributeWeights, - virtualQueuesData, - oooData, - }); + }) + ); - if (!usersAndTheirBookingShortfalls.length) { - usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; - } + let remainingAvailableUsers = [...availableUsers]; + let bookingsOfRemainingAvailableUsersOfInterval = [...bookingsOfAvailableUsersOfInterval]; + const orderedUsersSet = new Set(); + const perUserBookingsCount: Record = {}; - if (orderedUsersSet.has(luckyUser)) { - // It is helpful in breaking the loop as same user is returned again and again. - // Also, it tells a bug in the code. - throw new Error( - `Error building ordered list of lucky users. The lucky user ${luckyUser.email} is already in the set.` + const startTime = performance.now(); + let usersAndTheirBookingShortfalls: { + id: number; + bookingShortfall: number; + calibration: number; + weight: number; + }[] = []; + while (remainingAvailableUsers.length > 0) { + const { luckyUser, usersAndTheirBookingShortfalls: _usersAndTheirBookingShortfalls } = + this.getLuckyUser_requiresDataToBePreFetched({ + ...getLuckyUserParams, + eventType, + availableUsers: remainingAvailableUsers as [AvailableUser, ...AvailableUser[]], + bookingsOfAvailableUsersOfInterval: bookingsOfRemainingAvailableUsersOfInterval, + bookingsOfNotAvailableUsersOfInterval, + allRRHostsBookingsOfInterval, + allRRHostsCreatedInInterval, + organizersWithLastCreated, + attributeWeights, + virtualQueuesData, + oooData, + }); + + if (!usersAndTheirBookingShortfalls.length) { + usersAndTheirBookingShortfalls = _usersAndTheirBookingShortfalls; + } + + if (orderedUsersSet.has(luckyUser)) { + throw new Error( + `Error building ordered list of lucky users. The lucky user ${luckyUser.email} is already in the set.` + ); + } + + orderedUsersSet.add(luckyUser); + perUserBookingsCount[luckyUser.id] = bookingsOfAvailableUsersOfInterval.filter( + (booking) => booking.userId === luckyUser.id + ).length; + remainingAvailableUsers = remainingAvailableUsers.filter((user) => user.id !== luckyUser.id); + bookingsOfRemainingAvailableUsersOfInterval = bookingsOfRemainingAvailableUsersOfInterval.filter( + (booking) => remainingAvailableUsers.map((user) => user.id).includes(booking.userId ?? 0) ); } - orderedUsersSet.add(luckyUser); - perUserBookingsCount[luckyUser.id] = bookingsOfAvailableUsersOfInterval.filter( - (booking) => booking.userId === luckyUser.id - ).length; - remainingAvailableUsers = remainingAvailableUsers.filter((user) => user.id !== luckyUser.id); - bookingsOfRemainingAvailableUsersOfInterval = bookingsOfRemainingAvailableUsersOfInterval.filter( - (booking) => remainingAvailableUsers.map((user) => user.id).includes(booking.userId ?? 0) - ); + const endTime = performance.now(); + log.info(`getOrderedListOfLuckyUsers took ${endTime - startTime}ms`); + + const bookingShortfalls: Record = {}; + const calibrations: Record = {}; + const weights: Record = {}; + + usersAndTheirBookingShortfalls.forEach((user) => { + bookingShortfalls[user.id] = parseFloat(user.bookingShortfall.toFixed(2)); + calibrations[user.id] = parseFloat(user.calibration.toFixed(2)); + weights[user.id] = user.weight; + }); + + return { + users: Array.from(orderedUsersSet), + isUsingAttributeWeights: !!attributeWeights && !!virtualQueuesData, + perUserData: { + bookingsCount: perUserBookingsCount, + bookingShortfalls: eventType.isRRWeightsEnabled ? bookingShortfalls : null, + calibrations: eventType.isRRWeightsEnabled ? calibrations : null, + weights: eventType.isRRWeightsEnabled ? weights : null, + }, + }; } - - const endTime = performance.now(); - log.info(`getOrderedListOfLuckyUsers took ${endTime - startTime}ms`); - - const bookingShortfalls: Record = {}; - const calibrations: Record = {}; - const weights: Record = {}; - - usersAndTheirBookingShortfalls.forEach((user) => { - bookingShortfalls[user.id] = parseFloat(user.bookingShortfall.toFixed(2)); - calibrations[user.id] = parseFloat(user.calibration.toFixed(2)); - weights[user.id] = user.weight; - }); - - return { - users: Array.from(orderedUsersSet), - isUsingAttributeWeights: !!attributeWeights && !!virtualQueuesData, - perUserData: { - bookingsCount: perUserBookingsCount, - bookingShortfalls: eventType.isRRWeightsEnabled ? bookingShortfalls : null, - calibrations: eventType.isRRWeightsEnabled ? calibrations : null, - weights: eventType.isRRWeightsEnabled ? weights : null, - }, - }; -} - -export async function prepareQueuesAndAttributesData({ - eventType, - routingFormResponse, - allRRHosts, -}: Omit, "availableUsers">) { - let attributeWeights; - let virtualQueuesData; - const organizationId = eventType.team?.parentId; - log.debug("prepareQueuesAndAttributesData", safeStringify({ routingFormResponse, organizationId })); - if (routingFormResponse && organizationId) { - const routingForm = routingFormResponse?.form; - const routes = zodRoutes.parse(routingForm.routes); - const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId); - - if (chosenRoute && "attributeIdForWeights" in chosenRoute) { - const attributeIdForWeights = chosenRoute.attributeIdForWeights; - - const attributeWithEnabledWeights = await prisma.attribute.findUnique({ - where: { - id: attributeIdForWeights, - teamId: organizationId, - isWeightsEnabled: true, - }, - select: { - id: true, - name: true, - slug: true, - type: true, - options: { - select: { - id: true, - value: true, - slug: true, - assignedUsers: { - select: { - member: { - select: { - userId: true, - }, - }, - weight: true, - }, - }, - }, - }, - }, - }); - - if (attributeWithEnabledWeights) { - // Virtual queues are defined by the attribute that is used for weights - const queueAndAtributeWeightData = await getQueueAndAttributeWeightData( - allRRHosts, - routingFormResponse, - attributeWithEnabledWeights - ); - if ( - queueAndAtributeWeightData?.averageWeightsHosts && - queueAndAtributeWeightData?.virtualQueuesData - ) { - attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts; - virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData; - } - } - } - } - return { attributeWeights, virtualQueuesData }; -} - -async function getQueueAndAttributeWeightData( - allRRHosts: GetLuckyUserParams["allRRHosts"], - routingFormResponse: RoutingFormResponse, - attributeWithWeights: AttributeWithWeights -) { - let averageWeightsHosts: { userId: number; weight: number }[] = []; - - const chosenRouteId = routingFormResponse?.chosenRouteId ?? undefined; - - if (!chosenRouteId) return; - - let fieldOptionData: { fieldId: string; selectedOptionIds: string | number | string[] } | undefined; - - const routingForm = routingFormResponse?.form; - - if (routingForm && routingFormResponse) { - const response = routingFormResponse.response as FormResponse; - - const routes = zodRoutes.parse(routingForm.routes); - const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId); - - if (chosenRoute && "attributesQueryValue" in chosenRoute) { - const parsedAttributesQueryValue = raqbQueryValueSchema.parse(chosenRoute.attributesQueryValue); - - const attributesQueryValueWithLabel = getAttributesQueryValue({ - attributesQueryValue: chosenRoute.attributesQueryValue, - attributes: [attributeWithWeights], - dynamicFieldValueOperands: { - fields: (routingFormResponse.form.fields as Fields) || [], - response, - }, - }); - - const parsedAttributesQueryValueWithLabel = raqbQueryValueSchema.parse(attributesQueryValueWithLabel); - - if (parsedAttributesQueryValueWithLabel && parsedAttributesQueryValueWithLabel.children1) { - averageWeightsHosts = getAverageAttributeWeights( - allRRHosts, - parsedAttributesQueryValueWithLabel.children1, - attributeWithWeights - ); - } - - if (parsedAttributesQueryValue && parsedAttributesQueryValue.children1) { - fieldOptionData = getAttributesForVirtualQueues( - response, - parsedAttributesQueryValue.children1, - attributeWithWeights - ); - } - } - } - - if (fieldOptionData) { - return { averageWeightsHosts, virtualQueuesData: { chosenRouteId, fieldOptionData } }; - } - - return; -} - -function getAverageAttributeWeights< - T extends PartialUser & { - priority?: number | null; - weight?: number | null; - } ->( - allRRHosts: GetLuckyUserParams["allRRHosts"], - attributesQueryValueChild: Record< - string, - { - type?: string | undefined; - properties?: - | { - field?: any; - operator?: any; - value?: any; - valueSrc?: any; - } - | undefined; - } - >, - attributeWithWeights: AttributeWithWeights -) { - let averageWeightsHosts: { userId: number; weight: number }[] = []; - - const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({ - field: child.properties?.field, - value: child.properties?.value, - })); - - fieldValueArray.map((obj) => { - const attributeId = obj.field; - const allRRHostsWeights = new Map(); - - if (attributeId === attributeWithWeights.id) { - obj.value.forEach((arrayobj: string[]) => { - arrayobj.forEach((attributeOption: string) => { - // attributeOption is either optionId or label, we only care about labels here - const attributeOptionWithUsers = attributeWithWeights.options.find( - (option) => option.value.toLowerCase() === attributeOption.toLowerCase() - ); - - allRRHosts.forEach((rrHost) => { - //assignedUser can be undefined if fallback route is hit or in the case of crm ownership - const assignedUser = attributeOptionWithUsers?.assignedUsers.find( - (assignedUser) => rrHost.user.id === assignedUser.member.userId - ); - - if (allRRHostsWeights.has(rrHost.user.id)) { - allRRHostsWeights.get(rrHost.user.id)?.push(assignedUser?.weight ?? rrHost.weight ?? 100); - } else { - allRRHostsWeights.set(rrHost.user.id, [assignedUser?.weight ?? rrHost.weight ?? 100]); - } - }); - }); - }); - averageWeightsHosts = Array.from(allRRHostsWeights.entries()).map(([userId, weights]) => { - const totalWeight = weights.reduce((acc, weight) => acc + weight, 0); - const averageWeight = totalWeight / weights.length; - - return { - userId, - weight: averageWeight, - }; - }); - } - }); - log.debug( - "getAverageAttributeWeights", - safeStringify({ allRRHosts, attributesQueryValueChild, attributeWithWeights, averageWeightsHosts }) - ); - - return averageWeightsHosts; -} - -function getAttributesForVirtualQueues( - response: Record>, - attributesQueryValueChild: Record< - string, - { - type?: string | undefined; - properties?: - | { - field?: any; - operator?: any; - value?: any; - valueSrc?: any; - } - | undefined; - } - >, - attributeWithWeights: { id: string } -) { - let selectionOptions: Pick["fieldOptionData"] | undefined; - - const fieldValueArray = Object.values(attributesQueryValueChild).map((child) => ({ - field: child.properties?.field, - value: child.properties?.value, - })); - - fieldValueArray.some((obj) => { - const attributeId = obj.field; - - if (attributeId === attributeWithWeights.id) { - obj.value.some((arrayobj: string[]) => { - arrayobj.some((attributeOptionId: string) => { - const content = attributeOptionId.slice(1, -1); - - const routingFormFieldId = content.includes("field:") ? content.split("field:")[1] : null; - - if (routingFormFieldId) { - const fieldResponse = response[routingFormFieldId]; - selectionOptions = { fieldId: routingFormFieldId, selectedOptionIds: fieldResponse.value }; - return true; // break out of all loops - } - }); - }); - } - }); - return selectionOptions; } diff --git a/packages/lib/server/repository/PrismaAttributeRepository.ts b/packages/lib/server/repository/PrismaAttributeRepository.ts index b88e1043d9..d7684f65d0 100644 --- a/packages/lib/server/repository/PrismaAttributeRepository.ts +++ b/packages/lib/server/repository/PrismaAttributeRepository.ts @@ -55,4 +55,45 @@ export class PrismaAttributeRepository { }, }); } + + async findUniqueWithWeights({ + teamId, + attributeId, + isWeightsEnabled = true, + }: { + teamId: number; + attributeId: string; + isWeightsEnabled?: boolean; + }) { + return await this.prismaClient.attribute.findUnique({ + where: { + id: attributeId, + teamId, + isWeightsEnabled, + }, + select: { + id: true, + name: true, + slug: true, + type: true, + options: { + select: { + id: true, + value: true, + slug: true, + assignedUsers: { + select: { + member: { + select: { + userId: true, + }, + }, + weight: true, + }, + }, + }, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/host.ts b/packages/lib/server/repository/host.ts index 80ffef8397..5b42300b8a 100644 --- a/packages/lib/server/repository/host.ts +++ b/packages/lib/server/repository/host.ts @@ -14,4 +14,27 @@ export class HostRepository { }, }); } + + async findHostsCreatedInInterval({ + eventTypeId, + userIds, + startDate, + }: { + eventTypeId: number; + userIds: number[]; + startDate: Date; + }) { + return await this.prismaClient.host.findMany({ + where: { + userId: { + in: userIds, + }, + eventTypeId, + isFixed: false, + createdAt: { + gte: startDate, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/ooo.ts b/packages/lib/server/repository/ooo.ts index 20b8a50e8b..9752844858 100644 --- a/packages/lib/server/repository/ooo.ts +++ b/packages/lib/server/repository/ooo.ts @@ -80,70 +80,99 @@ export class PrismaOOORepository { }); } - async findUserOOODays({userId, dateTo, dateFrom}: {userId: number, dateTo: string, dateFrom: string}) { + async findUserOOODays({ userId, dateTo, dateFrom }: { userId: number; dateTo: string; dateFrom: string }) { return this.prismaClient.outOfOfficeEntry.findMany({ - where: { - userId, - OR: [ - // outside of range - // (start <= 'dateTo' AND end >= 'dateFrom') - { - start: { - lte: dateTo, - }, - end: { - gte: dateFrom, - }, - }, - // start is between dateFrom and dateTo but end is outside of range - // (start <= 'dateTo' AND end >= 'dateTo') - { - start: { - lte: dateTo, - }, - - end: { - gte: dateTo, - }, - }, - // end is between dateFrom and dateTo but start is outside of range - // (start <= 'dateFrom' OR end <= 'dateTo') - { - start: { - lte: dateFrom, - }, - - end: { - lte: dateTo, - }, - }, - ], + where: { + userId, + OR: [ + // outside of range + // (start <= 'dateTo' AND end >= 'dateFrom') + { + start: { + lte: dateTo, }, - select: { - id: true, - start: true, - end: true, - user: { - select: { - id: true, - name: true, - }, - }, - toUser: { - select: { - id: true, - username: true, - name: true, - }, - }, - reason: { - select: { - id: true, - emoji: true, - reason: true, - }, - }, + end: { + gte: dateFrom, }, - }); + }, + // start is between dateFrom and dateTo but end is outside of range + // (start <= 'dateTo' AND end >= 'dateTo') + { + start: { + lte: dateTo, + }, + + end: { + gte: dateTo, + }, + }, + // end is between dateFrom and dateTo but start is outside of range + // (start <= 'dateFrom' OR end <= 'dateTo') + { + start: { + lte: dateFrom, + }, + + end: { + lte: dateTo, + }, + }, + ], + }, + select: { + id: true, + start: true, + end: true, + user: { + select: { + id: true, + name: true, + }, + }, + toUser: { + select: { + id: true, + username: true, + name: true, + }, + }, + reason: { + select: { + id: true, + emoji: true, + reason: true, + }, + }, + }, + }); + } + + async findOOOEntriesInInterval({ + userIds, + startDate, + endDate, + }: { + userIds: number[]; + startDate: Date; + endDate: Date; + }) { + return this.prismaClient.outOfOfficeEntry.findMany({ + where: { + userId: { + in: userIds, + }, + start: { + lte: endDate, + }, + end: { + gte: startDate, + }, + }, + select: { + start: true, + end: true, + userId: true, + }, + }); } } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index ae5091913a..1f3ef50ebe 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -8,7 +8,7 @@ import { availabilityUserSelect } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma, User as UserType } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { UpId, UserProfile } from "@calcom/types/UserProfile"; @@ -1028,4 +1028,43 @@ export class UserRepository { }, }); } + + async findUsersWithLastBooking({ userIds, eventTypeId }: { userIds: number[]; eventTypeId: number }) { + return this.prismaClient.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + bookings: { + select: { + createdAt: true, + }, + where: { + eventTypeId, + status: BookingStatus.ACCEPTED, + attendees: { + some: { + noShow: false, + }, + }, + OR: [ + { + noShowHost: false, + }, + { + noShowHost: null, + }, + ], + }, + orderBy: { + createdAt: "desc", + }, + take: 1, + }, + }, + }); + } } diff --git a/packages/platform/libraries/bookings.ts b/packages/platform/libraries/bookings.ts index e63970f7a9..472bd0a520 100644 --- a/packages/platform/libraries/bookings.ts +++ b/packages/platform/libraries/bookings.ts @@ -1,2 +1,4 @@ +export { LuckyUserService } from "@calcom/lib/server/getLuckyUser"; + export { CheckBookingLimitsService } from "@calcom/lib/intervalLimits/server/checkBookingLimits"; export { CheckBookingAndDurationLimitsService } from "@calcom/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits"; diff --git a/packages/platform/libraries/repositories.ts b/packages/platform/libraries/repositories.ts index 1fea51735e..50d550f270 100644 --- a/packages/platform/libraries/repositories.ts +++ b/packages/platform/libraries/repositories.ts @@ -1,3 +1,4 @@ +export { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; export { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; export { ScheduleRepository as PrismaScheduleRepository } from "@calcom/lib/server/repository/schedule"; export { BookingRepository as PrismaBookingRepository } from "@calcom/lib/server/repository/booking"; @@ -8,3 +9,4 @@ export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repos export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user"; export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository"; export { MembershipRepository as PrismaMembershipRepository } from "@calcom/lib/server/repository/membership"; +export { HostRepository as PrismaHostRepository } from "@calcom/lib/server/repository/host"; diff --git a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts index 7094409005..ee1ec84db2 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/findTeamMembersMatchingAttributeLogicOfRoute.handler.ts @@ -10,10 +10,10 @@ import type { NextApiResponse } from "next"; import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData"; import { getUrlSearchParamsToForwardForTestPreview } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; import { enrichHostsWithDelegationCredentials } from "@calcom/lib/delegationCredential/server"; +import { getLuckyUserService } from "@calcom/lib/di/containers/LuckyUser"; import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils.server"; import { fromEntriesWithDuplicateKeys } from "@calcom/lib/fromEntriesWithDuplicateKeys"; import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; -import { getOrderedListOfLuckyUsers } from "@calcom/lib/server/getLuckyUser"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; @@ -270,12 +270,13 @@ export const findTeamMembersMatchingAttributeLogicOfRouteHandler = async ({ } const timeBeforeGetOrderedLuckyUsers = performance.now(); + const luckyUserService = getLuckyUserService(); const { users: orderedLuckyUsers, perUserData, isUsingAttributeWeights, } = matchingHosts.length - ? await getOrderedListOfLuckyUsers({ + ? await luckyUserService.getOrderedListOfLuckyUsers({ // Assuming all are available availableUsers: [ { diff --git a/yarn.lock b/yarn.lock index 32d87296d4..c1d9dfebca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,7 +2493,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.321" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.333" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3553,13 +3553,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.321": - version: 0.0.321 - resolution: "@calcom/platform-libraries@npm:0.0.321" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.333": + version: 0.0.333 + resolution: "@calcom/platform-libraries@npm:0.0.333" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 634b092311d9952d6ce1eec3a55fc18ab314d282d087ca7d9e9baf3f53f1e718e81d2b2cb6613f377d129c864290eb14f89770cfdd0c14e8968a34db093163f5 + checksum: 321c792aeb36ec69b7a02756ac550736bd9a71e4544fcebd9ab625bc2132edd5bf3e86295af05d797ff82de0ec35f3954c7409543e4325b7cbbbb55ce786ef60 languageName: node linkType: hard