fix: add InsightsBookingRepository (#22003)

* fix: add InsightsBookingRepository WIP

* write integration tests

* remove log

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
This commit is contained in:
Eunjae Lee
2025-06-26 07:57:08 +00:00
committed by GitHub
co-authored by Benny Joo
parent cf79a3f580
commit 3fd9ef366f
2 changed files with 867 additions and 0 deletions
@@ -0,0 +1,611 @@
import type { Team, User, Membership } from "@prisma/client";
import { describe, expect, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsBookingRepository } from "../insightsBooking";
// Helper function to create unique test data
async function createTestData({
teamRole = MembershipRole.MEMBER,
orgRole = MembershipRole.MEMBER,
moreTeamsAndUsers = [],
}: {
teamRole?: MembershipRole;
orgRole?: MembershipRole;
moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles
} = {}) {
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(7);
// Create test user
const user = await prisma.user.create({
data: {
email: `test-user-${timestamp}-${randomSuffix}@example.com`,
username: `testuser-${timestamp}-${randomSuffix}`,
name: "Test User",
},
});
// Create test organization
const org = await prisma.team.create({
data: {
name: `Test Org ${timestamp}-${randomSuffix}`,
slug: `test-org-${timestamp}-${randomSuffix}`,
isOrganization: true,
},
});
// Create test team under the organization
const team = await prisma.team.create({
data: {
name: `Test Team ${timestamp}-${randomSuffix}`,
slug: `test-team-${timestamp}-${randomSuffix}`,
isOrganization: false,
parentId: org.id,
},
});
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 60,
userId: user.id,
teamId: team.id,
},
});
// Create test booking
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${timestamp}-${randomSuffix}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
const membership = await prisma.membership.create({
data: {
userId: user.id,
teamId: team.id,
role: teamRole,
accepted: true,
},
});
const orgMembership = await prisma.membership.create({
data: {
userId: user.id,
teamId: org.id,
role: orgRole,
accepted: true,
},
});
// Create additional teams and users based on moreTeamsAndUsers
const additionalTeams: Team[] = [];
const additionalUsers: User[] = [];
const additionalMemberships: Membership[] = [];
for (let teamIndex = 0; teamIndex < moreTeamsAndUsers.length; teamIndex++) {
const teamUserRoles = moreTeamsAndUsers[teamIndex];
// Create the team
const additionalTeam = await prisma.team.create({
data: {
name: `Test Team ${teamIndex + 2} ${timestamp}-${randomSuffix}`,
slug: `test-team-${teamIndex + 2}-${timestamp}-${randomSuffix}`,
isOrganization: false,
parentId: org.id,
},
});
additionalTeams.push(additionalTeam);
// Create users for this team
for (let userIndex = 0; userIndex < teamUserRoles.length; userIndex++) {
const userRole = teamUserRoles[userIndex];
// Create the user
const additionalUser = await prisma.user.create({
data: {
email: `test-user-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}@example.com`,
username: `testuser-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}`,
name: `Test User Team${teamIndex + 2}-${userIndex + 1}`,
},
});
additionalUsers.push(additionalUser);
// Create the membership
const additionalMembership = await prisma.membership.create({
data: {
userId: additionalUser.id,
teamId: additionalTeam.id,
role: userRole,
accepted: true,
},
});
additionalMemberships.push(additionalMembership);
}
}
// Return test data and cleanup function
return {
user,
org,
orgMembership,
team,
eventType,
booking,
membership,
additionalTeams,
additionalUsers,
additionalMemberships,
cleanup: async () => {
// Clean up additional memberships
for (const membership of additionalMemberships) {
await prisma.membership.delete({
where: { id: membership.id },
});
}
// Clean up additional users
for (const user of additionalUsers) {
await prisma.user.delete({
where: { id: user.id },
});
}
// Clean up additional teams
for (const team of additionalTeams) {
await prisma.team.delete({
where: { id: team.id },
});
}
// Clean up main test data
await prisma.booking.deleteMany({
where: { id: booking.id },
});
await prisma.eventType.deleteMany({
where: { id: eventType.id },
});
await prisma.membership.deleteMany({
where: { id: membership.id },
});
await prisma.membership.delete({
where: { id: orgMembership.id },
});
await prisma.team.deleteMany({
where: { id: team.id },
});
await prisma.team.deleteMany({
where: { id: org.id },
});
await prisma.user.deleteMany({
where: { id: user.id },
});
},
};
}
describe("InsightsBookingRepository Integration Tests", () => {
describe("Authorization Conditions", () => {
it("should return NOTHING for invalid options", async () => {
const repository = new InsightsBookingRepository({
prisma,
options: null as any,
});
const conditions = await repository.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
});
it("should return NOTHING for non-owner/admin user", async () => {
const testData = await createTestData();
// Create a regular user who is not an owner/admin
const regularUser = await prisma.user.create({
data: {
email: `regular-user-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`,
username: `regularuser-${Date.now()}-${Math.random().toString(36).substring(7)}`,
name: "Regular User",
},
});
// Add the user as a regular member (not owner/admin) to the organization
const membership = await prisma.membership.create({
data: {
userId: regularUser.id,
teamId: testData.org.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "org",
userId: regularUser.id,
orgId: testData.org.id,
},
});
const conditions = await repository.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
// Clean up
await prisma.membership.delete({
where: { id: membership.id },
});
await prisma.user.delete({
where: { id: regularUser.id },
});
await testData.cleanup();
});
it("should build user scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await repository.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
userId: testData.user.id,
teamId: null,
},
],
});
await testData.cleanup();
});
it("should build team scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "team",
userId: testData.user.id,
orgId: testData.org.id,
teamId: testData.team.id,
},
});
const conditions = await repository.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
OR: [
{
teamId: testData.team.id,
isTeamBooking: true,
},
{
userId: {
in: [testData.user.id],
},
isTeamBooking: false,
},
],
},
],
});
// Clean up
await testData.cleanup();
});
it("should build org scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
moreTeamsAndUsers: [
[MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role
[MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role
],
});
// Get references to additional data
const user2 = testData.additionalUsers[0]; // First user from first team
const user3 = testData.additionalUsers[1]; // First user from second team
const team2 = testData.additionalTeams[0]; // First additional team
const team3 = testData.additionalTeams[1]; // Second additional team
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await repository.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
OR: [
{
teamId: {
in: [testData.org.id, testData.team.id, team2.id, team3.id],
},
isTeamBooking: true,
},
{
userId: {
in: [testData.user.id, user2.id, user3.id],
},
isTeamBooking: false,
},
],
},
],
});
await testData.cleanup();
});
});
describe("Filter Conditions", () => {
it("should return null when no filters are provided", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await repository.getFilterConditions();
expect(conditions).toBeNull();
await testData.cleanup();
});
it("should build eventTypeId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
eventTypeId: testData.eventType.id,
},
});
const conditions = await repository.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
},
],
});
await testData.cleanup();
});
it("should build memberUserId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
memberUserId: testData.user.id,
},
});
const conditions = await repository.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
userId: testData.user.id,
},
],
});
await testData.cleanup();
});
it("should combine multiple filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
eventTypeId: testData.eventType.id,
memberUserId: testData.user.id,
},
});
const conditions = await repository.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
},
{
userId: testData.user.id,
},
],
});
await testData.cleanup();
});
});
describe("Caching", () => {
it("should cache authorization conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
// First call should build conditions
const conditions1 = await repository.getAuthorizationConditions();
expect(conditions1).toEqual({
AND: [
{
userId: testData.user.id,
teamId: null,
},
],
});
// Second call should use cached conditions
const conditions2 = await repository.getAuthorizationConditions();
expect(conditions2).toEqual(conditions1);
// Clean up
await testData.cleanup();
});
it("should cache filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
eventTypeId: testData.eventType.id,
},
});
// First call should build conditions
const conditions1 = await repository.getFilterConditions();
expect(conditions1).toEqual({
AND: [
{
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
},
],
});
// Second call should use cached conditions
const conditions2 = await repository.getFilterConditions();
expect(conditions2).toEqual(conditions1);
await testData.cleanup();
});
});
describe("findMany", () => {
it("should combine authorization and filter conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create a user-scoped event type (not team-scoped)
const userEventType = await prisma.eventType.create({
data: {
title: "User Event Type",
slug: "user-event-type",
length: 60,
userId: testData.user.id,
teamId: null, // User-scoped, not team-scoped
},
});
// Create a booking for the user-scoped event type
const userBooking = await prisma.booking.create({
data: {
uid: `user-booking-${Date.now()}-${Math.random().toString(36).substring(7)}`,
title: "User Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: testData.user.id,
eventTypeId: userEventType.id,
status: BookingStatus.PENDING,
},
});
const repository = new InsightsBookingRepository({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
filters: {
eventTypeId: userEventType.id,
},
});
const results = await repository.findMany({
select: {
id: true,
title: true,
},
});
// Should return the user booking since it matches both conditions
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(userBooking.id);
// Clean up
await prisma.booking.delete({
where: { id: userBooking.id },
});
await prisma.eventType.delete({
where: { id: userEventType.id },
});
await testData.cleanup();
});
});
});
@@ -0,0 +1,256 @@
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { readonlyPrisma } from "@calcom/prisma";
export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal("user"),
userId: z.number(),
orgId: z.number(),
}),
z.object({
scope: z.literal("org"),
userId: z.number(),
orgId: z.number(),
}),
z.object({
scope: z.literal("team"),
userId: z.number(),
orgId: z.number(),
teamId: z.number(),
}),
]);
export type InsightsBookingRepositoryOptions = z.infer<typeof insightsBookingRepositoryOptionsSchema>;
export type InsightsBookingRepositoryFilterOptions = {
eventTypeId?: number;
memberUserId?: number;
};
const NOTHING = {
id: -1,
} as const;
export class InsightsBookingRepository {
private prisma: typeof readonlyPrisma;
private options: InsightsBookingRepositoryOptions | null;
private filters?: InsightsBookingRepositoryFilterOptions;
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
constructor({
prisma,
options,
filters,
}: {
prisma: typeof readonlyPrisma;
options: InsightsBookingRepositoryOptions;
filters?: InsightsBookingRepositoryFilterOptions;
}) {
this.prisma = prisma;
const validation = insightsBookingRepositoryOptionsSchema.safeParse(options);
this.options = validation.success ? validation.data : null;
this.filters = filters;
}
async findMany(findManyArgs: Prisma.BookingTimeStatusDenormalizedFindManyArgs) {
const authConditions = await this.getAuthorizationConditions();
const filterConditions = await this.getFilterConditions();
return this.prisma.bookingTimeStatusDenormalized.findMany({
...findManyArgs,
where: {
...findManyArgs.where,
AND: [authConditions, filterConditions].filter(
(c): c is Prisma.BookingTimeStatusDenormalizedWhereInput => c !== null
),
},
});
}
async getAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
async getFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
async buildFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
if (!this.filters) {
return null;
}
if (this.filters.eventTypeId) {
conditions.push({
OR: [{ eventTypeId: this.filters.eventTypeId }, { eventParentId: this.filters.eventTypeId }],
});
}
if (this.filters.memberUserId) {
conditions.push({
userId: this.filters.memberUserId,
});
}
return conditions.length > 0 ? { AND: conditions } : null;
}
async buildAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
if (!this.options) {
return NOTHING;
}
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
if (!isOwnerOrAdmin) {
return NOTHING;
}
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
if (this.options.scope === "user") {
conditions.push({
userId: this.options.userId,
teamId: null,
});
} else if (this.options.scope === "org") {
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
} else if (this.options.scope === "team") {
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
} else {
return NOTHING;
}
return {
AND: conditions,
};
}
private async buildOrgAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "org" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await this.prisma.team.findMany({
where: {
parentId: options.orgId,
},
select: {
id: true,
},
});
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
// Get all users from the organization
const userIdsFromOrg =
teamsFromOrg.length > 0
? (
await this.prisma.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
select: {
userId: true,
},
})
).map((m) => m.userId)
: [];
return {
OR: [
{
teamId: {
in: teamIds,
},
isTeamBooking: true,
},
...(userIdsFromOrg.length > 0
? [
{
userId: {
in: Array.from(new Set(userIdsFromOrg)),
},
isTeamBooking: false,
},
]
: []),
],
};
}
private async buildTeamAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "team" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
const childTeamOfOrg = await this.prisma.team.findFirst({
where: {
id: options.teamId,
parentId: options.orgId,
},
});
if (!childTeamOfOrg) {
return NOTHING;
}
const usersFromTeam = await this.prisma.membership.findMany({
where: {
teamId: options.teamId,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
return {
OR: [
{
teamId: options.teamId,
isTeamBooking: true,
},
{
userId: {
in: userIdsFromTeam,
},
isTeamBooking: false,
},
],
};
}
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
// Check if the user is an owner or admin of the organization
const membershipOrg = await this.prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId: orgId,
},
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
select: {
id: true,
},
});
return Boolean(membershipOrg);
}
}