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