refactor: convert lucky user to service class (#23329)

* refactor: lucky user to service class with DI

* fix: unit test filterHostsByLeadThreshold.test.ts

* fixup! fix: unit test filterHostsByLeadThreshold.test.ts

* fix: get lucky user test missing mocks

* fix: get lucky user hosts not within interval

* chore: bump platform library

* chore: bump platform library

* chore: add DAILY_API_KEY to turborepo.json db seed

* chore: add DAILY_API_KEY to cache-db action

* fixup! chore: add DAILY_API_KEY to cache-db action

* chore: remove log in fixture

* chore: bump platform library

* chore: refactor test team-emails.e2e

* only failing test

* run all api v2 tests

* chore: bump platform library

* revert change to turbo.json and action in cache-db

* chore: use inPerson location in team-emails.e2e
This commit is contained in:
Morgan
2025-08-26 08:43:41 -03:00
committed by GitHub
parent ae738fae35
commit ba8cca55e8
26 changed files with 1806 additions and 2002 deletions
+1 -1
View File
@@ -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": "*",
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
@@ -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);
}
}
@@ -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,
});
}
}
@@ -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,
@@ -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
@@ -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
@@ -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 },
@@ -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 extends BaseUser> = {
user: User;
};
type PerUserData = Awaited<ReturnType<typeof getOrderedListOfLuckyUsers>>["perUserData"];
type PerUserData = Awaited<
ReturnType<(typeof LuckyUserService.prototype)["getOrderedListOfLuckyUsers"]>
>["perUserData"];
type WeightedPerUserData = Omit<PerUserData, "weights" | "calibrations" | "bookingShortfalls"> & {
weights: NonNullable<PerUserData["weights"]>;
calibrations: NonNullable<PerUserData["calibrations"]>;
@@ -116,7 +118,8 @@ export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
}
// 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,
+25
View File
@@ -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<LuckyUserService>(DI_TOKENS.LUCKY_USER_SERVICE);
}
+9
View File
@@ -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]);
+7
View File
@@ -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]);
+13
View File
@@ -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,
});
+6
View File
@@ -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"),
};
@@ -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)],
});
+146 -55
View File
@@ -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, ...T[]];
type GetLuckyUserAvailableUsersType = NonEmptyArray<ReturnType<typeof buildUser>>;
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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,
},
},
},
},
},
});
}
}
+23
View File
@@ -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,
},
},
});
}
}
+91 -62
View File
@@ -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,
},
});
}
}
+40 -1
View File
@@ -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,
},
},
});
}
}
+2
View File
@@ -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";
@@ -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";
@@ -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: [
{
+5 -5
View File
@@ -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