fix: add InsightsRoutingService and refactor InsightsBookingRepository (#22057)

* chore: convert InsightsBookingRepository to service

* remove repository

* rename

* clean up

* add InsightsRoutingService

* add tests
This commit is contained in:
Eunjae Lee
2025-06-27 11:08:46 +02:00
committed by GitHub
parent e8607845e2
commit 0a3b3a69e3
9 changed files with 993 additions and 96 deletions
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
}: GetAttributesForTeamHandlerOptions) {
const { teamId } = input;
const { user } = ctx;
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
if (!isMemberOfTeam) {
throw new TRPCError({
+21 -1
View File
@@ -263,7 +263,7 @@ export class MembershipRepository {
});
}
static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
static async findUniqueByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
return await prisma.membership.findUnique({
where: {
userId_teamId: {
@@ -398,4 +398,24 @@ export class MembershipRepository {
},
});
}
static async findAllByTeamIds({
teamIds,
select = { userId: true },
}: {
teamIds: number[];
select?: Prisma.MembershipSelect;
}) {
return prisma.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
select,
});
}
}
+33
View File
@@ -186,6 +186,39 @@ export class TeamRepository {
return getParsedTeam(team);
}
static async findAllByParentId({
parentId,
select = teamSelect,
}: {
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findMany({
where: {
parentId,
},
select,
});
}
static async findByIdAndParentId({
id,
parentId,
select = teamSelect,
}: {
id: number;
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findFirst({
where: {
id,
parentId,
},
select,
});
}
static async deleteById({ id }: { id: number }) {
try {
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsBookingRepository } from "../insightsBooking";
import { InsightsBookingService } from "../../service/insightsBooking";
// Helper function to create unique test data
async function createTestData({
@@ -195,15 +195,15 @@ async function createTestData({
};
}
describe("InsightsBookingRepository Integration Tests", () => {
describe("InsightsBookingService Integration Tests", () => {
describe("Authorization Conditions", () => {
it("should return NOTHING for invalid options", async () => {
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: null as any,
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
});
@@ -229,7 +229,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "org",
@@ -238,7 +238,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ id: -1 });
// Clean up
@@ -257,7 +257,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -266,7 +266,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
@@ -285,7 +285,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "team",
@@ -295,7 +295,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
@@ -335,7 +335,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
const team2 = testData.additionalTeams[0]; // First additional team
const team3 = testData.additionalTeams[1]; // Second additional team
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "org",
@@ -344,7 +344,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getAuthorizationConditions();
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
@@ -375,7 +375,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should return null when no filters are provided", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -384,7 +384,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toBeNull();
await testData.cleanup();
@@ -393,7 +393,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should build eventTypeId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -405,7 +405,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -420,7 +420,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should build memberUserId filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -432,7 +432,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -447,7 +447,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should combine multiple filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -460,7 +460,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const conditions = await repository.getFilterConditions();
const conditions = await service.getFilterConditions();
expect(conditions).toEqual({
AND: [
{
@@ -483,7 +483,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -493,7 +493,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// First call should build conditions
const conditions1 = await repository.getAuthorizationConditions();
const conditions1 = await service.getAuthorizationConditions();
expect(conditions1).toEqual({
AND: [
{
@@ -504,7 +504,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// Second call should use cached conditions
const conditions2 = await repository.getAuthorizationConditions();
const conditions2 = await service.getAuthorizationConditions();
expect(conditions2).toEqual(conditions1);
// Clean up
@@ -514,7 +514,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
it("should cache filter conditions", async () => {
const testData = await createTestData();
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -527,7 +527,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// First call should build conditions
const conditions1 = await repository.getFilterConditions();
const conditions1 = await service.getFilterConditions();
expect(conditions1).toEqual({
AND: [
{
@@ -537,7 +537,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
});
// Second call should use cached conditions
const conditions2 = await repository.getFilterConditions();
const conditions2 = await service.getFilterConditions();
expect(conditions2).toEqual(conditions1);
await testData.cleanup();
@@ -575,7 +575,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const repository = new InsightsBookingRepository({
const service = new InsightsBookingService({
prisma,
options: {
scope: "user",
@@ -587,7 +587,7 @@ describe("InsightsBookingRepository Integration Tests", () => {
},
});
const results = await repository.findMany({
const results = await service.findMany({
select: {
id: true,
title: true,
@@ -0,0 +1,705 @@
import type { Team, User, Membership } from "@prisma/client";
import { randomUUID } from "crypto";
import { describe, expect, it } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingService } from "../../service/insightsRouting";
// 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
} = {}) {
// Create test user
const user = await prisma.user.create({
data: {
email: `test-user-${randomUUID()}@example.com`,
username: `testuser-${randomUUID()}`,
name: "Test User",
},
});
// Create test organization
const org = await prisma.team.create({
data: {
name: "Test Org",
slug: `test-org-${randomUUID()}`,
isOrganization: true,
},
});
// Create test team under the organization
const team = await prisma.team.create({
data: {
name: "Test Team",
slug: `test-team-${randomUUID()}`,
isOrganization: false,
parentId: org.id,
},
});
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event Type",
slug: `test-event-type-${randomUUID()}`,
length: 60,
userId: user.id,
teamId: team.id,
},
});
// Create test booking
const booking = await prisma.booking.create({
data: {
uid: `test-booking-${randomUUID()}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
// Create test routing form
const form = await prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
userId: user.id,
teamId: team.id,
fields: [
{
id: `text-field-id-${randomUUID()}`,
type: "text",
label: "Name",
required: true,
},
{
id: `email-field-id-${randomUUID()}`,
type: "email",
label: "Email",
required: true,
},
],
},
});
// Create test form response
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `test-filler-${randomUUID()}`,
formId: form.id,
response: {
[`text-field-id-${randomUUID()}`]: {
label: "Name",
value: "Test Name",
},
[`email-field-id-${randomUUID()}`]: {
label: "Email",
value: "test@example.com",
},
},
routedToBookingUid: booking.uid,
},
});
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 i = 0; i < moreTeamsAndUsers.length; i++) {
const teamRoles = moreTeamsAndUsers[i];
const additionalTeam = await prisma.team.create({
data: {
name: `Additional Team ${i + 1}`,
slug: `additional-team-${i + 1}-${randomUUID()}`,
isOrganization: false,
parentId: org.id,
},
});
additionalTeams.push(additionalTeam);
for (let j = 0; j < teamRoles.length; j++) {
const role = teamRoles[j];
const additionalUser = await prisma.user.create({
data: {
email: `additional-user-${i}-${j}-${randomUUID()}@example.com`,
username: `additionaluser-${i}-${j}-${randomUUID()}`,
name: `Additional User ${i}-${j}`,
},
});
additionalUsers.push(additionalUser);
const additionalMembership = await prisma.membership.create({
data: {
userId: additionalUser.id,
teamId: additionalTeam.id,
role: role,
accepted: true,
},
});
additionalMemberships.push(additionalMembership);
}
}
// Cleanup function
const cleanup = async () => {
// Delete in reverse order to avoid foreign key constraints
for (const membership of additionalMemberships) {
await prisma.membership.delete({
where: { id: membership.id },
});
}
for (const user of additionalUsers) {
await prisma.user.delete({
where: { id: user.id },
});
}
for (const team of additionalTeams) {
await prisma.team.delete({
where: { id: team.id },
});
}
await prisma.membership.delete({
where: { id: orgMembership.id },
});
await prisma.membership.delete({
where: { id: membership.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
await prisma.booking.delete({
where: { id: booking.id },
});
await prisma.eventType.delete({
where: { id: eventType.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: form.id },
});
await prisma.team.delete({
where: { id: team.id },
});
await prisma.team.delete({
where: { id: org.id },
});
await prisma.user.delete({
where: { id: user.id },
});
};
return {
user,
org,
team,
eventType,
booking,
form,
formResponse,
membership,
orgMembership,
additionalTeams,
additionalUsers,
additionalMemberships,
cleanup,
};
}
describe("InsightsRoutingService Integration Tests", () => {
describe("Authorization Conditions", () => {
it("should return NOTHING for invalid options", async () => {
const service = new InsightsRoutingService({
prisma,
options: null as any,
});
const conditions = await service.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-${randomUUID()}@example.com`,
username: `regularuser-${randomUUID()}`,
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 service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: regularUser.id,
orgId: testData.org.id,
},
});
const conditions = await service.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 service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formUserId: testData.user.id,
},
],
});
await testData.cleanup();
});
it("should build team scope conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "team",
userId: testData.user.id,
orgId: testData.org.id,
teamId: testData.team.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formTeamId: testData.team.id,
},
],
});
// 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 team2 = testData.additionalTeams[0]; // First additional team
const team3 = testData.additionalTeams[1]; // Second additional team
const service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({
AND: [
{
formTeamId: {
in: [testData.org.id, testData.team.id, team2.id, team3.id],
},
},
],
});
await testData.cleanup();
});
it("should return NOTHING for team scope when team is not child of org", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create a team that is not a child of the org
const unrelatedTeam = await prisma.team.create({
data: {
name: "Unrelated Team",
slug: `unrelated-team-${randomUUID()}`,
isOrganization: false,
parentId: null, // Not part of the org
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "team",
userId: testData.user.id,
orgId: testData.org.id,
teamId: unrelatedTeam.id,
},
});
const conditions = await service.getAuthorizationConditions();
expect(conditions).toEqual({ AND: [{ id: -1 }] });
// Clean up
await prisma.team.delete({
where: { id: unrelatedTeam.id },
});
await testData.cleanup();
});
});
describe("Filter Conditions", () => {
it("should return null when no filters are provided", async () => {
const testData = await createTestData();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const conditions = await service.getFilterConditions();
expect(conditions).toBeNull();
await testData.cleanup();
});
});
describe("Caching", () => {
it("should cache authorization conditions", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
// First call should build conditions
const conditions1 = await service.getAuthorizationConditions();
expect(conditions1).toEqual({
AND: [
{
formUserId: testData.user.id,
},
],
});
// Second call should use cached conditions
const conditions2 = await service.getAuthorizationConditions();
expect(conditions2).toEqual(conditions1);
// Clean up
await testData.cleanup();
});
it("should cache filter conditions", async () => {
const testData = await createTestData();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
// First call should build conditions
const conditions1 = await service.getFilterConditions();
expect(conditions1).toBeNull();
// Second call should use cached conditions
const conditions2 = await service.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,
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should return the user form response since it matches the authorization conditions
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(testData.formResponse.id);
await testData.cleanup();
});
it("should filter out unauthorized responses", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create another user and form
const otherUser = await prisma.user.create({
data: {
email: `other-user-${randomUUID()}@example.com`,
username: `otheruser-${randomUUID()}`,
name: "Other User",
},
});
const otherForm = await prisma.app_RoutingForms_Form.create({
data: {
name: "Other User Form",
userId: otherUser.id,
teamId: null,
fields: [
{
id: "text-field-id",
type: "text",
label: "Name",
required: true,
},
],
},
});
const otherFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `other-form-filler-${randomUUID()}`,
formId: otherForm.id,
response: {
"text-field-id": {
label: "Name",
value: "Other Form Response",
},
},
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should only return the authorized user's form response
expect(results).toHaveLength(1);
expect(results[0]?.id).toBe(testData.formResponse.id);
expect(results[0]?.id).not.toBe(otherFormResponse.id);
// Clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: otherFormResponse.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: otherForm.id },
});
await prisma.user.delete({
where: { id: otherUser.id },
});
await testData.cleanup();
});
it("should fetch all forms in org scope when user is in same team", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
// Create another user in the same team
const teamMember = await prisma.user.create({
data: {
email: `team-member-${randomUUID()}@example.com`,
username: `teammember-${randomUUID()}`,
name: "Team Member",
},
});
// Add the team member to the same team
const teamMembership = await prisma.membership.create({
data: {
userId: teamMember.id,
teamId: testData.team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
// Create a form under the team member
const teamMemberForm = await prisma.app_RoutingForms_Form.create({
data: {
name: "Team Member Form",
userId: teamMember.id,
teamId: testData.team.id, // Same team as the original user
fields: [
{
id: `text-field-id-${randomUUID()}`,
type: "text",
label: "Name",
required: true,
},
],
},
});
// Create a form response for the team member's form
const teamMemberFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: `team-member-form-filler-${randomUUID()}`,
formId: teamMemberForm.id,
response: {
[`text-field-id-${randomUUID()}`]: {
label: "Name",
value: "Team Member Form Response",
},
},
},
});
const service = new InsightsRoutingService({
prisma,
options: {
scope: "org",
userId: testData.user.id,
orgId: testData.org.id,
},
});
const results = await service.findMany({
select: {
id: true,
formName: true,
},
});
// Should return both form responses (original user's and team member's)
expect(results).toHaveLength(2);
const responseIds = results.map((r) => r.id).sort();
const expectedIds = [testData.formResponse.id, teamMemberFormResponse.id].sort();
expect(responseIds).toEqual(expectedIds);
// Clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: teamMemberFormResponse.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: teamMemberForm.id },
});
await prisma.membership.delete({
where: { id: teamMembership.id },
});
await prisma.user.delete({
where: { id: teamMember.id },
});
await testData.cleanup();
});
});
});
@@ -2,8 +2,12 @@ import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scope", [
import { MembershipRepository } from "../repository/membership";
import { TeamRepository } from "../repository/team";
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal("user"),
userId: z.number(),
@@ -22,9 +26,9 @@ export const insightsBookingRepositoryOptionsSchema = z.discriminatedUnion("scop
}),
]);
export type InsightsBookingRepositoryOptions = z.infer<typeof insightsBookingRepositoryOptionsSchema>;
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
export type InsightsBookingRepositoryFilterOptions = {
export type InsightsBookingServiceFilterOptions = {
eventTypeId?: number;
memberUserId?: number;
};
@@ -33,10 +37,10 @@ const NOTHING = {
id: -1,
} as const;
export class InsightsBookingRepository {
export class InsightsBookingService {
private prisma: typeof readonlyPrisma;
private options: InsightsBookingRepositoryOptions | null;
private filters?: InsightsBookingRepositoryFilterOptions;
private options: InsightsBookingServiceOptions | null;
private filters?: InsightsBookingServiceFilterOptions;
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
@@ -46,12 +50,11 @@ export class InsightsBookingRepository {
filters,
}: {
prisma: typeof readonlyPrisma;
options: InsightsBookingRepositoryOptions;
filters?: InsightsBookingRepositoryFilterOptions;
options: InsightsBookingServiceOptions;
filters?: InsightsBookingServiceFilterOptions;
}) {
this.prisma = prisma;
const validation = insightsBookingRepositoryOptionsSchema.safeParse(options);
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
this.options = validation.success ? validation.data : null;
this.filters = filters;
@@ -138,37 +141,21 @@ export class InsightsBookingRepository {
}
private async buildOrgAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "org" }>
options: Extract<InsightsBookingServiceOptions, { 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 teamsFromOrg = await TeamRepository.findAllByParentId({
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)
? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map(
(m) => m.userId
)
: [];
return {
@@ -194,26 +181,20 @@ export class InsightsBookingRepository {
}
private async buildTeamAuthorizationCondition(
options: Extract<InsightsBookingRepositoryOptions, { scope: "team" }>
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
const childTeamOfOrg = await this.prisma.team.findFirst({
where: {
id: options.teamId,
parentId: options.orgId,
},
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
});
if (!childTeamOfOrg) {
return NOTHING;
}
const usersFromTeam = await this.prisma.membership.findMany({
where: {
teamId: options.teamId,
accepted: true,
},
select: {
userId: true,
},
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
teamIds: [options.teamId],
select: { userId: true },
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
@@ -235,22 +216,12 @@ export class InsightsBookingRepository {
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);
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
return Boolean(
membership &&
membership.accepted &&
membership.role &&
([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role)
);
}
}
@@ -0,0 +1,168 @@
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import type { readonlyPrisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRepository } from "../repository/membership";
import { TeamRepository } from "../repository/team";
export const insightsRoutingServiceOptionsSchema = 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 InsightsRoutingServiceOptions = z.infer<typeof insightsRoutingServiceOptionsSchema>;
export type InsightsRoutingServiceFilterOptions = {
// Empty for now
};
const NOTHING = {
id: -1,
} as const;
export class InsightsRoutingService {
private prisma: typeof readonlyPrisma;
private options: InsightsRoutingServiceOptions | null;
private filters?: InsightsRoutingServiceFilterOptions;
private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
constructor({
prisma,
options,
filters,
}: {
prisma: typeof readonlyPrisma;
options: InsightsRoutingServiceOptions;
filters?: InsightsRoutingServiceFilterOptions;
}) {
this.prisma = prisma;
const validation = insightsRoutingServiceOptionsSchema.safeParse(options);
this.options = validation.success ? validation.data : null;
this.filters = filters;
}
async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
const authConditions = await this.getAuthorizationConditions();
const filterConditions = await this.getFilterConditions();
return this.prisma.routingFormResponseDenormalized.findMany({
...findManyArgs,
where: {
...findManyArgs.where,
AND: [authConditions, filterConditions].filter(
(c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
),
},
});
}
async getAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
if (this.cachedAuthConditions === undefined) {
this.cachedAuthConditions = await this.buildAuthorizationConditions();
}
return this.cachedAuthConditions;
}
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
if (this.cachedFilterConditions === undefined) {
this.cachedFilterConditions = await this.buildFilterConditions();
}
return this.cachedFilterConditions;
}
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
// Empty for now
return null;
}
async buildAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
if (!this.options) {
return NOTHING;
}
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
if (!isOwnerOrAdmin) {
return NOTHING;
}
const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
if (this.options.scope === "user") {
conditions.push({
formUserId: this.options.userId,
});
} 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<InsightsRoutingServiceOptions, { scope: "org" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await TeamRepository.findAllByParentId({
parentId: options.orgId,
select: { id: true },
});
return {
formTeamId: {
in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
},
};
}
private async buildTeamAuthorizationCondition(
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
});
if (!childTeamOfOrg) {
return NOTHING;
}
return {
formTeamId: options.teamId,
};
}
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
// Check if the user is an owner or admin of the organization
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
return Boolean(
membership &&
membership.accepted &&
membership.role &&
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
);
}
}
@@ -418,7 +418,7 @@ export const assignValueToUserInOrgBulk = async ({
attributeLabelToValueMap: AttributeLabelToValueMap;
updater: BulkAttributeAssigner;
}) => {
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
if (!membership) {
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
@@ -14,7 +14,7 @@ type GetDataOptions = {
};
export const get = async ({ ctx, input }: GetDataOptions) => {
const teamMembership = await MembershipRepository.findFirstByUserIdAndTeamId({
const teamMembership = await MembershipRepository.findUniqueByUserIdAndTeamId({
userId: ctx.user.id,
teamId: input.teamId,
});