From 0590d02a369e0475725e24a277ab1bf9cbe4274b Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 17 Jul 2025 14:12:01 +0200 Subject: [PATCH 01/13] refactor: examples app setup --- .../platform/examples/base/src/pages/_app.tsx | 16 +- .../base/src/pages/api/managed-user.ts | 327 +++++++----------- 2 files changed, 140 insertions(+), 203 deletions(-) diff --git a/packages/platform/examples/base/src/pages/_app.tsx b/packages/platform/examples/base/src/pages/_app.tsx index f28e99ef04993d..d35433c5841269 100644 --- a/packages/platform/examples/base/src/pages/_app.tsx +++ b/packages/platform/examples/base/src/pages/_app.tsx @@ -13,8 +13,8 @@ import "@calcom/atoms/globals.min.css"; const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] }); type TUser = Data["users"][0]; -function generateRandomEmail() { - const localPartLength = 10; +function generateRandomEmail(name: string) { + const localPartLength = 5; const domain = ["example.com", "example.net", "example.org"]; const randomLocalPart = Array.from({ length: localPartLength }, () => @@ -23,7 +23,7 @@ function generateRandomEmail() { const randomDomain = domain[Math.floor(Math.random() * domain.length)]; - return `${randomLocalPart}@${randomDomain}`; + return `${name}-${randomLocalPart}@${randomDomain}`; } // note(Lauris): needed because useEffect kicks in twice creating 2 parallel requests @@ -50,11 +50,11 @@ export default function App({ Component, pageProps }: AppProps) { }, []); useEffect(() => { - const randomEmailOne = generateRandomEmail(); - const randomEmailTwo = generateRandomEmail(); - const randomEmailThree = generateRandomEmail(); - const randomEmailFour = generateRandomEmail(); - const randomEmailFive = generateRandomEmail(); + const randomEmailOne = generateRandomEmail("keith"); + const randomEmailTwo = generateRandomEmail("somay"); + const randomEmailThree = generateRandomEmail("rajiv"); + const randomEmailFour = generateRandomEmail("morgan"); + const randomEmailFive = generateRandomEmail("lauris"); if (!seeding) { seeding = true; diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index 4cbb1c4355b775..ee6013ac3bcbfc 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -12,56 +12,14 @@ type Data = { accessToken: string; }; -// example endpoint to create a managed cal.com user -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { emails } = JSON.parse(req.body); - const emailOne = emails[0]; - const emailTwo = emails[1]; - const emailThree = emails[2]; - const emailFour = emails[3]; - const emailFive = emails[4]; - - const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); - if (existingUser && existingUser.calcomUserId) { - return res.status(200).json({ - id: existingUser.calcomUserId, - email: existingUser.email, - username: existingUser.calcomUsername ?? "", - accessToken: existingUser.accessToken ?? "", - }); - } - - const localUserOne = await prisma.user.create({ - data: { - email: emailOne, - }, - }); - - const localUserTwo = await prisma.user.create({ +async function createUserWithDefaultSchedule(email: string, name: string, avatarUrl: string) { + const localUser = await prisma.user.create({ data: { - email: emailTwo, + email, }, }); - const localUserThree = await prisma.user.create({ - data: { - email: emailThree, - }, - }); - - const localUserFour = await prisma.user.create({ - data: { - email: emailFour, - }, - }); - - const localUserFive = await prisma.user.create({ - data: { - email: emailFive, - }, - }); - - const response = await fetch( + const managedUserResponse = await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, { @@ -73,153 +31,74 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< origin: "http://localhost:4321", }, body: JSON.stringify({ - email: emailOne, - name: "John Jones", - avatarUrl: - "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", + email, + name, + avatarUrl, }), } ); - const body = await response.json(); - await prisma.user.update({ - data: { - refreshToken: (body.data?.refreshToken as string) ?? "", - accessToken: (body.data?.accessToken as string) ?? "", - calcomUserId: body.data?.user.id, - calcomUsername: (body.data?.user.username as string) ?? "", - }, - where: { id: localUserOne.id }, - }); + const managedUserResponseBody = await managedUserResponse.json(); - const responseTwo = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailTwo, - name: "Jane Doe", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyTwo = await responseTwo.json(); await prisma.user.update({ data: { - refreshToken: (bodyTwo.data?.refreshToken as string) ?? "", - accessToken: (bodyTwo.data?.accessToken as string) ?? "", - calcomUserId: bodyTwo.data?.user.id, - calcomUsername: (bodyTwo.data?.user.username as string) ?? "", + refreshToken: (managedUserResponseBody.data?.refreshToken as string) ?? "", + accessToken: (managedUserResponseBody.data?.accessToken as string) ?? "", + calcomUserId: managedUserResponseBody.data?.user.id, + calcomUsername: (managedUserResponseBody.data?.user.username as string) ?? "", }, - where: { id: localUserTwo.id }, + where: { id: localUser.id }, }); - const responseThree = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailThree, - name: "Rajiv", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyThree = await responseThree.json(); + await createDefaultSchedule(managedUserResponseBody.data?.accessToken as string); - await prisma.user.update({ - data: { - refreshToken: (bodyThree.data?.refreshToken as string) ?? "", - accessToken: (bodyThree.data?.accessToken as string) ?? "", - calcomUserId: bodyThree.data?.user.id, - calcomUsername: (bodyThree.data?.user.username as string) ?? "", - }, - where: { id: localUserThree.id }, - }); + return managedUserResponseBody.data; +} - const responseFour = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailFour, - name: "Morgan", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } - ); - const bodyFour = await responseFour.json(); +// example endpoint to create a managed cal.com user +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { emails } = JSON.parse(req.body); + const emailOne = emails[0]; + const emailTwo = emails[1]; + const emailThree = emails[2]; + const emailFour = emails[3]; + const emailFive = emails[4]; - await prisma.user.update({ - data: { - refreshToken: (bodyFour.data?.refreshToken as string) ?? "", - accessToken: (bodyFour.data?.accessToken as string) ?? "", - calcomUserId: bodyFour.data?.user.id, - calcomUsername: (bodyFour.data?.user.username as string) ?? "", - }, - where: { id: localUserFour.id }, - }); + const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } }); + if (existingUser && existingUser.calcomUserId) { + return res.status(200).json({ + id: existingUser.calcomUserId, + email: existingUser.email, + username: existingUser.calcomUsername ?? "", + accessToken: existingUser.accessToken ?? "", + }); + } - const responseFive = await fetch( - // eslint-disable-next-line turbo/no-undeclared-env-vars - `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars - [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", - origin: "http://localhost:4321", - }, - body: JSON.stringify({ - email: emailFive, - name: "Lauris", - avatarUrl: - "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }), - } + const managedUserResponseOne = await createUserWithDefaultSchedule( + emailOne, + "Keith", + "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseTwo = await createUserWithDefaultSchedule( + emailTwo, + "Somay", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseThree = await createUserWithDefaultSchedule( + emailThree, + "Rajiv", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseFour = await createUserWithDefaultSchedule( + emailFour, + "Morgan", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + ); + const managedUserResponseFive = await createUserWithDefaultSchedule( + emailFive, + "Lauris", + "https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" ); - const bodyFive = await responseFive.json(); - - await prisma.user.update({ - data: { - refreshToken: (bodyFive.data?.refreshToken as string) ?? "", - accessToken: (bodyFive.data?.accessToken as string) ?? "", - calcomUserId: bodyFive.data?.user.id, - calcomUsername: (bodyFive.data?.user.username as string) ?? "", - }, - where: { id: localUserFive.id }, - }); - - await createDefaultSchedule(body.data?.accessToken as string); - await createDefaultSchedule(bodyTwo.data?.accessToken as string); - await createDefaultSchedule(bodyThree.data?.accessToken as string); - await createDefaultSchedule(bodyFour.data?.accessToken as string); - await createDefaultSchedule(bodyFive.data?.accessToken as string); // eslint-disable-next-line turbo/no-undeclared-env-vars const organizationId = process.env.ORGANIZATION_ID; @@ -227,27 +106,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< throw new Error("Organization ID is not set"); } - const team = await createTeam(+organizationId, "Team Doe"); + const team = await createTeam(+organizationId, `Platform devs - ${Date.now()}`); if (!team) { throw new Error("Failed to create team. Probably your platform team does not have required plan."); } - await createMembership(+organizationId, team.id, body.data?.user.id); - await createMembership(+organizationId, team.id, bodyTwo.data?.user.id); - await createMembership(+organizationId, team.id, bodyThree.data?.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseOne.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseTwo.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseThree.user.id); + await createOrgTeamMembershipMember(+organizationId, team.id, managedUserResponseFour.user.id); + await createCollectiveEventType(+organizationId, team.id, [ - body.data?.user.id, - bodyTwo.data?.user.id, - bodyThree.data?.user.id, - bodyFour.data?.user.id, - bodyFive.data?.user.id, + managedUserResponseOne.user.id, + managedUserResponseTwo.user.id, + managedUserResponseThree.user.id, + managedUserResponseFour.user.id, ]); + await createRoundRobinEventType(+organizationId, team.id, [ + managedUserResponseOne.user.id, + managedUserResponseTwo.user.id, + managedUserResponseThree.user.id, + managedUserResponseFour.user.id, + ]); + + await createOrgMembershipAdmin(+organizationId, managedUserResponseFive.user.id); + return res.status(200).json({ - id: body?.data?.user?.id, - email: (body.data?.user.email as string) ?? "", - username: (body.data?.username as string) ?? "", - accessToken: (body.data?.accessToken as string) ?? "", + id: managedUserResponseOne?.user?.id, + email: (managedUserResponseOne.user.email as string) ?? "", + username: (managedUserResponseOne.user.username as string) ?? "", + accessToken: (managedUserResponseOne.accessToken as string) ?? "", }); } @@ -276,10 +165,33 @@ async function createTeam(orgId: number, name: string) { return body.data; } -async function createMembership(orgId: number, teamId: number, userId: number) { +async function createOrgTeamMembershipMember(orgId: number, teamId: number, userId: number) { await fetch( // eslint-disable-next-line turbo/no-undeclared-env-vars `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + userId, + accepted: true, + role: "MEMBER", + }), + } + ); +} + +async function createOrgMembershipAdmin(orgId: number, userId: number) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/memberships`, { method: "POST", headers: { @@ -315,9 +227,34 @@ async function createCollectiveEventType(orgId: number, teamId: number, userIds: }, body: JSON.stringify({ lengthInMinutes: 60, - title: "Doe collective", - slug: "doe-collective", - schedulingType: "COLLECTIVE", + title: "Platform example collective", + slug: "platform-example-collective", + schedulingType: "collective", + hosts: userIds.map((userId) => ({ userId })), + }), + } + ); +} + +async function createRoundRobinEventType(orgId: number, teamId: number, userIds: number[]) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + lengthInMinutes: 60, + title: "Platform example round robin", + slug: "platform-example-round-robin", + schedulingType: "roundRobin", hosts: userIds.map((userId) => ({ userId })), }), } From db22d5ef44144486bf4230888e66e37c33329702 Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 17 Jul 2025 15:52:03 +0200 Subject: [PATCH 02/13] wip: view org event types as org administrator --- .../services/event-types-atom.service.ts | 49 +++++++++++++++++-- .../organizations-membership.service.ts | 13 +++++ .../index/organizations-teams.controller.ts | 17 ++++--- .../index/organizations-teams.repository.ts | 13 +++++ .../services/organizations-teams.service.ts | 9 ++++ 5 files changed, 91 insertions(+), 10 deletions(-) diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index d5613afabd647b..63f45845872dc4 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -75,10 +75,14 @@ export class EventTypesAtomService { ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) : false; + const effectiveUserId = isUserOrganizationAdmin + ? await this.getUserIdAssociatedWithEventType(eventTypeId) + : user.id; + const eventType = await getEventTypeById({ currentOrganizationId: this.usersService.getUserMainOrgId(user), eventTypeId, - userId: user.id, + userId: effectiveUserId, prisma: this.dbRead.prisma as unknown as PrismaClient, isUserOrganizationAdmin, isTrpcCall: true, @@ -89,9 +93,9 @@ export class EventTypesAtomService { } if (eventType?.team?.id) { - await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); + await this.checkTeamOwnsEventType(effectiveUserId, eventType.eventType.id, eventType.team.id); } else { - this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType); + this.eventTypeService.checkUserOwnsEventType(effectiveUserId, eventType.eventType); } // note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type @@ -439,4 +443,43 @@ export class EventTypesAtomService { throw new NotFoundException(`Event type with slug ${eventSlug} not found`); } } + + async getUserIdAssociatedWithEventType(eventTypeId: number) { + const event = await this.dbRead.prisma.eventType.findUnique({ + where: { + id: eventTypeId, + }, + }); + if (!event) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + if (event.userId) { + return event.userId; + } + + if (event.teamId) { + const team = await this.dbRead.prisma.team.findUnique({ + where: { + id: event.teamId, + }, + select: { + members: { + select: { + userId: true, + }, + }, + }, + }); + if (!team) { + throw new NotFoundException(`Team with id ${event.teamId} not found`); + } + if (!team.members.length) { + throw new NotFoundException(`Team with id ${event.teamId} has no members`); + } + return team.members[0].userId; + } + + throw new NotFoundException(`Event type with id ${eventTypeId} has no user or team associated`); + } } diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index a8af33579b060c..1103d14381c140 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -27,6 +27,19 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } + async isOrgAdminOrOwner(organizationId: number, userId: number) { + const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( + organizationId, + userId + ); + if (!membership) { + throw new NotFoundException( + `Membership for user with id ${userId} within organization id ${organizationId} not found` + ); + } + return membership.role === "ADMIN" || membership.role === "OWNER"; + } + async getOrgMembershipByUserId(organizationId: number, userId: number) { const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( organizationId, diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts index 25cda044c06edd..1c4411eb318c63 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts @@ -14,6 +14,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; +import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input"; import { @@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client"; @ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) @ApiHeader(OPTIONAL_API_KEY_HEADER) export class OrganizationsTeamsController { - constructor(private organizationsTeamsService: OrganizationsTeamsService) {} + constructor( + private organizationsTeamsService: OrganizationsTeamsService, + private organizationsMembershipService: OrganizationsMembershipService + ) {} @Get() @ApiOperation({ summary: "Get all teams" }) @@ -84,12 +88,11 @@ export class OrganizationsTeamsController { @GetUser() user: UserWithProfile ): Promise { const { skip, take } = queryParams; - const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams( - orgId, - user.id, - skip ?? 0, - take ?? 250 - ); + const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id); + const teams = isOrgAdminOrOwner + ? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250) + : await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250); + return { status: SUCCESS_STATUS, data: teams.map((team) => { diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts index 9acf452aea51d2..27f905f3a86824 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.repository.ts @@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository { take, }); } + + async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + }, + include: { + members: { select: { accepted: true, userId: true, role: true } }, + }, + skip, + take, + }); + } } diff --git a/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts b/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts index a9f305cb456c8d..5a5ea2e5a2d6f0 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/services/organizations-teams.service.ts @@ -24,6 +24,15 @@ export class OrganizationsTeamsService { return teams; } + async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) { + const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers( + organizationId, + skip, + take + ); + return teams; + } + async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) { const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take); return teams; From 617e5730e42fbb3e962934a58af8e65d765c8b12 Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 17 Jul 2025 16:53:36 +0200 Subject: [PATCH 03/13] fix: create event type has org admin --- .../trpc/server/routers/viewer/eventTypes/create.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index c96103b0c8101c..159e15772b3b07 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -89,7 +89,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { if ( !isSystemAdmin && - (!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || isOrgAdmin)) + (!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || !isOrgAdmin)) ) { console.warn(`User ${userId} does not have permission to create this new event type`); throw new TRPCError({ code: "UNAUTHORIZED" }); From 3c3fe834a174a9e68bff5e1766db19896085cba3 Mon Sep 17 00:00:00 2001 From: supalarry Date: Thu, 17 Jul 2025 17:09:06 +0200 Subject: [PATCH 04/13] fix: create event type has org admin --- .../trpc/server/routers/viewer/eventTypes/create.handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 159e15772b3b07..ab2e4e6f04ff5c 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -89,7 +89,8 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { if ( !isSystemAdmin && - (!hasMembership?.role || !(["ADMIN", "OWNER"].includes(hasMembership.role) || !isOrgAdmin)) + !isOrgAdmin && + (!hasMembership?.role || !["ADMIN", "OWNER"].includes(hasMembership.role)) ) { console.warn(`User ${userId} does not have permission to create this new event type`); throw new TRPCError({ code: "UNAUTHORIZED" }); From d1d4b2bfdebbfbc5f45587499acd153711d1ce36 Mon Sep 17 00:00:00 2001 From: supalarry Date: Fri, 18 Jul 2025 16:18:09 +0200 Subject: [PATCH 05/13] fix: allow org admin team event type access --- apps/api/v2/src/modules/atoms/atoms.module.ts | 2 + .../services/event-types-atom.service.ts | 85 +++++++------------ packages/lib/event-types/getEventTypeById.ts | 7 +- packages/lib/server/repository/eventType.ts | 74 ++++++++++++---- .../examples/base/src/pages/event-types.tsx | 1 + 5 files changed, 99 insertions(+), 70 deletions(-) diff --git a/apps/api/v2/src/modules/atoms/atoms.module.ts b/apps/api/v2/src/modules/atoms/atoms.module.ts index c7071eabbb1cc2..2d825de5e57d2f 100644 --- a/apps/api/v2/src/modules/atoms/atoms.module.ts +++ b/apps/api/v2/src/modules/atoms/atoms.module.ts @@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { OrganizationsModule } from "@/modules/organizations/organizations.module"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisService } from "@/modules/redis/redis.service"; import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module"; @@ -21,6 +22,7 @@ import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule], providers: [ + OrganizationsTeamsRepository, EventTypesAtomService, ConferencingAtomsService, AttributesAtomsService, diff --git a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts index 63f45845872dc4..36d3c9d56143ab 100644 --- a/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts +++ b/apps/api/v2/src/modules/atoms/services/event-types-atom.service.ts @@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/ import { AtomsRepository } from "@/modules/atoms/atoms.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; @@ -53,7 +54,8 @@ export class EventTypesAtomService { private readonly dbWrite: PrismaWriteService, private readonly dbRead: PrismaReadService, private readonly eventTypeService: EventTypesService_2024_06_14, - private readonly teamEventTypeService: TeamsEventTypesService + private readonly teamEventTypeService: TeamsEventTypesService, + private readonly organizationsTeamsRepository: OrganizationsTeamsRepository ) {} private async getTeamSlug(teamId: number): Promise { @@ -75,14 +77,10 @@ export class EventTypesAtomService { ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) : false; - const effectiveUserId = isUserOrganizationAdmin - ? await this.getUserIdAssociatedWithEventType(eventTypeId) - : user.id; - const eventType = await getEventTypeById({ currentOrganizationId: this.usersService.getUserMainOrgId(user), eventTypeId, - userId: effectiveUserId, + userId: user.id, prisma: this.dbRead.prisma as unknown as PrismaClient, isUserOrganizationAdmin, isTrpcCall: true, @@ -92,10 +90,12 @@ export class EventTypesAtomService { throw new NotFoundException(`Event type with id ${eventTypeId} not found`); } - if (eventType?.team?.id) { - await this.checkTeamOwnsEventType(effectiveUserId, eventType.eventType.id, eventType.team.id); - } else { - this.eventTypeService.checkUserOwnsEventType(effectiveUserId, eventType.eventType); + if (!isUserOrganizationAdmin) { + if (eventType?.team?.id) { + await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); + } else { + this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType); + } } // note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type @@ -119,7 +119,8 @@ export class EventTypesAtomService { user: UserWithProfile, teamId: number ) { - await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId); + await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId); + const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user); const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined; @@ -179,14 +180,31 @@ export class EventTypesAtomService { } async checkCanUpdateTeamEventType( - userId: number, + user: UserWithProfile, eventTypeId: number, teamId: number, scheduleId: number | null | undefined ) { - await this.checkTeamOwnsEventType(userId, eventTypeId, teamId); + const organizationId = this.usersService.getUserMainOrgId(user); + + if (organizationId) { + const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin( + user.id, + organizationId + ); + + if (isUserOrganizationAdmin) { + const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId); + if (orgTeam) { + await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId); + return; + } + } + } + + await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId); await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId); - await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId); + await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId); } async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) { @@ -443,43 +461,4 @@ export class EventTypesAtomService { throw new NotFoundException(`Event type with slug ${eventSlug} not found`); } } - - async getUserIdAssociatedWithEventType(eventTypeId: number) { - const event = await this.dbRead.prisma.eventType.findUnique({ - where: { - id: eventTypeId, - }, - }); - if (!event) { - throw new NotFoundException(`Event type with id ${eventTypeId} not found`); - } - - if (event.userId) { - return event.userId; - } - - if (event.teamId) { - const team = await this.dbRead.prisma.team.findUnique({ - where: { - id: event.teamId, - }, - select: { - members: { - select: { - userId: true, - }, - }, - }, - }); - if (!team) { - throw new NotFoundException(`Team with id ${event.teamId} not found`); - } - if (!team.members.length) { - throw new NotFoundException(`Team with id ${event.teamId} has no members`); - } - return team.members[0].userId; - } - - throw new NotFoundException(`Event type with id ${eventTypeId} has no user or team associated`); - } } diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 886fa85dc33b41..c6f1a1c9e73346 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -53,7 +53,12 @@ export const getEventTypeById = async ({ } satisfies Prisma.UserSelect; const eventTypeRepo = new EventTypeRepository(prisma); - const rawEventType = await eventTypeRepo.findById({ id: eventTypeId, userId }); + const rawEventType = await eventTypeRepo.findById({ + id: eventTypeId, + userId, + isUserOrganizationAdmin, + currentOrganizationId, + }); if (!rawEventType) { if (isTrpcCall) { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index a88a230d02251b..8d5c8f60c20ef2 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -468,7 +468,17 @@ export class EventTypeRepository { }); } - async findById({ id, userId }: { id: number; userId: number }) { + async findById({ + id, + userId, + isUserOrganizationAdmin = false, + currentOrganizationId, + }: { + id: number; + userId: number; + isUserOrganizationAdmin?: boolean; + currentOrganizationId?: number | null; + }) { const userSelect = { name: true, avatarUrl: true, @@ -720,25 +730,57 @@ export class EventTypeRepository { // This is more efficient than using a complex join with team.members in the query const userTeamIds = await MembershipRepository.findUserTeamIds({ userId }); - return await this.prismaClient.eventType.findFirst({ - where: { + const eventTypeOwnerAccessConditions = [ + { + users: { + some: { + id: userId, + }, + }, + }, + { + AND: [{ teamId: { not: null } }, { teamId: { in: userTeamIds } }], + }, + { + userId: userId, + }, + ]; + + const orgAdminConditions = []; + if (isUserOrganizationAdmin && currentOrganizationId) { + const organizationUsersEventTypesQuery = { AND: [ + { userId: { not: null } }, { - OR: [ - { - users: { - some: { - id: userId, - }, + owner: { + profiles: { + some: { + organizationId: currentOrganizationId, }, }, - { - AND: [{ teamId: { not: null } }, { teamId: { in: userTeamIds } }], - }, - { - userId: userId, - }, - ], + }, + }, + ], + }; + const organizationTeamsEventTypesQuery = { + AND: [ + { teamId: { not: null } }, + { + team: { + parentId: currentOrganizationId, + }, + }, + ], + }; + orgAdminConditions.push(organizationUsersEventTypesQuery); + orgAdminConditions.push(organizationTeamsEventTypesQuery); + } + + return await this.prismaClient.eventType.findFirst({ + where: { + AND: [ + { + OR: [...eventTypeOwnerAccessConditions, ...orgAdminConditions], }, { id, diff --git a/packages/platform/examples/base/src/pages/event-types.tsx b/packages/platform/examples/base/src/pages/event-types.tsx index 894364fb6fbf4b..26b4319f2b9114 100644 --- a/packages/platform/examples/base/src/pages/event-types.tsx +++ b/packages/platform/examples/base/src/pages/event-types.tsx @@ -728,6 +728,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string onSuccess={(eventType) => { setEventTypeId(null); refetch(); + refetchTeamEvents(); }} onError={(eventType, error) => { console.log(eventType); From 75e51721c3a3450989b6905a838865ee329e840f Mon Sep 17 00:00:00 2001 From: supalarry Date: Tue, 22 Jul 2025 11:20:44 +0200 Subject: [PATCH 06/13] fix: uncaught error --- .../memberships/services/organizations-membership.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index 1103d14381c140..2e5e818cfd6222 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -33,9 +33,7 @@ export class OrganizationsMembershipService { userId ); if (!membership) { - throw new NotFoundException( - `Membership for user with id ${userId} within organization id ${organizationId} not found` - ); + return false; } return membership.role === "ADMIN" || membership.role === "OWNER"; } From 276b033d6a1ab3e21c40c116bd0583ece9a1203a Mon Sep 17 00:00:00 2001 From: supalarry Date: Tue, 22 Jul 2025 11:44:04 +0200 Subject: [PATCH 07/13] chore: bump platform libs --- apps/api/v2/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 735536af3c0711..319b4bb4010103 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.264", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/yarn.lock b/yarn.lock index a7ba5789eddbd6..c35d5ba909d673 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,7 +2519,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.264" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3566,13 +3566,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.258": - version: 0.0.258 - resolution: "@calcom/platform-libraries@npm:0.0.258" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.264": + version: 0.0.264 + resolution: "@calcom/platform-libraries@npm:0.0.264" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: ca522eee904921d4a2f285ba6ed195118a6d68c76fb454d826574d1f5487e8d3d7e41915c968ad0eff6bb01730578ee414069684c7a395b0c19b910f136bffb7 + checksum: db9e9e0caf7b2827c00db7f70d744063069b52db1b5e778c7495c1bbb910fdcb6d16d8e44d44e84437d3cc8da3aebbfadeb53f0d9072fc02c514f5327e671de0 languageName: node linkType: hard From a61d04370965be84aef563a641f0a9aaeaa36214 Mon Sep 17 00:00:00 2001 From: supalarry Date: Wed, 23 Jul 2025 11:42:06 +0200 Subject: [PATCH 08/13] refactor: getting event type for org admin --- packages/lib/event-types/getEventTypeById.ts | 28 +- .../server/repository/eventTypeRepository.ts | 581 +++++++++--------- 2 files changed, 305 insertions(+), 304 deletions(-) diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index 0817e8eef58cb2..c8769b0251be06 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -52,12 +52,12 @@ export const getEventTypeById = async ({ isPlatformManaged: true, } satisfies Prisma.UserSelect; - const eventTypeRepo = new EventTypeRepository(prisma); - const rawEventType = await eventTypeRepo.findById({ - id: eventTypeId, + const rawEventType = await getRawEventType({ userId, + eventTypeId, isUserOrganizationAdmin, currentOrganizationId, + prisma, }); if (!rawEventType) { @@ -264,4 +264,26 @@ export const getEventTypeById = async ({ return finalObj; }; +async function getRawEventType({ + userId, + eventTypeId, + isUserOrganizationAdmin, + currentOrganizationId, + prisma, +}: Omit) { + const eventTypeRepo = new EventTypeRepository(prisma); + + if (isUserOrganizationAdmin && currentOrganizationId) { + return await eventTypeRepo.findByIdForOrgAdmin({ + id: eventTypeId, + organizationId: currentOrganizationId, + }); + } + + return await eventTypeRepo.findById({ + id: eventTypeId, + userId, + }); +} + export default getEventTypeById; diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index 8d5c8f60c20ef2..c2debf928a6624 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -68,6 +68,254 @@ function usersWithSelectedCalendars< return users.map((user) => withSelectedCalendars(user)); } +const findByIdUserSelect = { + name: true, + avatarUrl: true, + username: true, + id: true, + email: true, + locale: true, + defaultScheduleId: true, + isPlatformManaged: true, +} satisfies Prisma.UserSelect; + +const findByIdCompleteEventTypeSelect = { + id: true, + title: true, + slug: true, + description: true, + interfaceLanguage: true, + length: true, + isInstantEvent: true, + instantMeetingExpiryTimeOffsetInSeconds: true, + instantMeetingParameters: true, + aiPhoneCallConfig: true, + offsetStart: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + timeZone: true, + periodType: true, + metadata: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + lockTimeZoneToggleOnBookingPage: true, + requiresConfirmation: true, + requiresConfirmationForFreeEmail: true, + canSendCalVideoTranscriptionEmails: true, + requiresConfirmationWillBlockSlot: true, + requiresBookerEmailVerification: true, + autoTranslateDescriptionEnabled: true, + fieldTranslations: { + select: { + translatedText: true, + targetLocale: true, + field: true, + }, + }, + recurringEvent: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + disableGuests: true, + disableCancelling: true, + disableRescheduling: true, + allowReschedulingCancelledBookings: true, + minimumBookingNotice: true, + beforeEventBuffer: true, + afterEventBuffer: true, + slotInterval: true, + hashedLink: true, + eventTypeColor: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, + maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, + assignAllTeamMembers: true, + allowReschedulingPastBookings: true, + hideOrganizerEmail: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, + isRRWeightsEnabled: true, + rescheduleWithSameRoundRobinHost: true, + successRedirectUrl: true, + forwardParamsSuccessRedirect: true, + currency: true, + bookingFields: true, + useEventTypeDestinationCalendarEmail: true, + customReplyToEmail: true, + owner: { + select: { + id: true, + }, + }, + parent: { + select: { + id: true, + teamId: true, + }, + }, + teamId: true, + team: { + select: { + id: true, + name: true, + slug: true, + parentId: true, + rrTimestampBasis: true, + parent: { + select: { + slug: true, + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, + }, + }, + members: { + select: { + role: true, + accepted: true, + user: { + select: { + ...findByIdUserSelect, + eventTypes: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + }, + }, + restrictionScheduleId: true, + useBookerTimezone: true, + users: { + select: findByIdUserSelect, + }, + schedulingType: true, + schedule: { + select: { + id: true, + name: true, + }, + }, + instantMeetingSchedule: { + select: { + id: true, + name: true, + }, + }, + restrictionSchedule: { + select: { + id: true, + name: true, + }, + }, + hosts: { + select: { + isFixed: true, + userId: true, + priority: true, + weight: true, + scheduleId: true, + }, + }, + userId: true, + price: true, + children: { + select: { + owner: { + select: { + avatarUrl: true, + name: true, + username: true, + email: true, + id: true, + }, + }, + hidden: true, + slug: true, + }, + }, + destinationCalendar: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + webhooks: { + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + eventTypeId: true, + }, + }, + workflows: { + include: { + workflow: { + select: { + name: true, + id: true, + trigger: true, + time: true, + timeUnit: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + parentId: true, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + steps: true, + }, + }, + }, + }, + secondaryEmailId: true, + maxLeadThreshold: true, + includeNoShowInRRCalculation: true, + useEventLevelSelectedCalendars: true, + calVideoSettings: { + select: { + disableRecordingForGuests: true, + disableRecordingForOrganizer: true, + enableAutomaticTranscription: true, + enableAutomaticRecordingForOrganizer: true, + disableTranscriptionForGuests: true, + disableTranscriptionForOrganizer: true, + redirectUrlOnExit: true, + }, + }, +} satisfies Prisma.EventTypeSelect; + export class EventTypeRepository { constructor(private prismaClient: PrismaClient) {} @@ -468,326 +716,57 @@ export class EventTypeRepository { }); } - async findById({ - id, - userId, - isUserOrganizationAdmin = false, - currentOrganizationId, - }: { - id: number; - userId: number; - isUserOrganizationAdmin?: boolean; - currentOrganizationId?: number | null; - }) { - const userSelect = { - name: true, - avatarUrl: true, - username: true, - id: true, - email: true, - locale: true, - defaultScheduleId: true, - isPlatformManaged: true, - } satisfies Prisma.UserSelect; - - const CompleteEventTypeSelect = { - id: true, - title: true, - slug: true, - description: true, - interfaceLanguage: true, - length: true, - isInstantEvent: true, - instantMeetingExpiryTimeOffsetInSeconds: true, - instantMeetingParameters: true, - aiPhoneCallConfig: true, - offsetStart: true, - hidden: true, - locations: true, - eventName: true, - customInputs: true, - timeZone: true, - periodType: true, - metadata: true, - periodDays: true, - periodStartDate: true, - periodEndDate: true, - periodCountCalendarDays: true, - lockTimeZoneToggleOnBookingPage: true, - requiresConfirmation: true, - requiresConfirmationForFreeEmail: true, - canSendCalVideoTranscriptionEmails: true, - requiresConfirmationWillBlockSlot: true, - requiresBookerEmailVerification: true, - autoTranslateDescriptionEnabled: true, - fieldTranslations: { - select: { - translatedText: true, - targetLocale: true, - field: true, - }, - }, - recurringEvent: true, - hideCalendarNotes: true, - hideCalendarEventDetails: true, - disableGuests: true, - disableCancelling: true, - disableRescheduling: true, - allowReschedulingCancelledBookings: true, - minimumBookingNotice: true, - beforeEventBuffer: true, - afterEventBuffer: true, - slotInterval: true, - hashedLink: true, - eventTypeColor: true, - bookingLimits: true, - onlyShowFirstAvailableSlot: true, - durationLimits: true, - maxActiveBookingsPerBooker: true, - maxActiveBookingPerBookerOfferReschedule: true, - assignAllTeamMembers: true, - allowReschedulingPastBookings: true, - hideOrganizerEmail: true, - assignRRMembersUsingSegment: true, - rrSegmentQueryValue: true, - isRRWeightsEnabled: true, - rescheduleWithSameRoundRobinHost: true, - successRedirectUrl: true, - forwardParamsSuccessRedirect: true, - currency: true, - bookingFields: true, - useEventTypeDestinationCalendarEmail: true, - customReplyToEmail: true, - owner: { - select: { - id: true, - }, - }, - parent: { - select: { - id: true, - teamId: true, - }, - }, - teamId: true, - team: { - select: { - id: true, - name: true, - slug: true, - parentId: true, - rrTimestampBasis: true, - parent: { - select: { - slug: true, - organizationSettings: { - select: { - lockEventTypeCreationForUsers: true, - }, - }, - }, - }, - members: { - select: { - role: true, - accepted: true, - user: { - select: { - ...userSelect, - eventTypes: { - select: { - slug: true, - }, - }, - }, - }, - }, - }, - }, - }, - restrictionScheduleId: true, - useBookerTimezone: true, - users: { - select: userSelect, - }, - schedulingType: true, - schedule: { - select: { - id: true, - name: true, - }, - }, - instantMeetingSchedule: { - select: { - id: true, - name: true, - }, - }, - restrictionSchedule: { - select: { - id: true, - name: true, - }, - }, - hosts: { - select: { - isFixed: true, - userId: true, - priority: true, - weight: true, - scheduleId: true, - }, - }, - userId: true, - price: true, - children: { - select: { - owner: { - select: { - avatarUrl: true, - name: true, - username: true, - email: true, - id: true, - }, - }, - hidden: true, - slug: true, - }, - }, - destinationCalendar: true, - seatsPerTimeSlot: true, - seatsShowAttendees: true, - seatsShowAvailabilityCount: true, - webhooks: { - select: { - id: true, - subscriberUrl: true, - payloadTemplate: true, - active: true, - eventTriggers: true, - secret: true, - eventTypeId: true, - }, - }, - workflows: { - include: { - workflow: { - select: { - name: true, - id: true, - trigger: true, - time: true, - timeUnit: true, - userId: true, - teamId: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - parentId: true, - _count: { - select: { - children: true, - }, - }, - }, - }, - }, - }, - steps: true, - }, - }, - }, - }, - secondaryEmailId: true, - maxLeadThreshold: true, - includeNoShowInRRCalculation: true, - useEventLevelSelectedCalendars: true, - calVideoSettings: { - select: { - disableRecordingForGuests: true, - disableRecordingForOrganizer: true, - enableAutomaticTranscription: true, - enableAutomaticRecordingForOrganizer: true, - disableTranscriptionForGuests: true, - disableTranscriptionForOrganizer: true, - redirectUrlOnExit: true, - }, - }, - } satisfies Prisma.EventTypeSelect; - + async findById({ id, userId }: { id: number; userId: number }) { // This is more efficient than using a complex join with team.members in the query const userTeamIds = await MembershipRepository.findUserTeamIds({ userId }); - const eventTypeOwnerAccessConditions = [ - { - users: { - some: { - id: userId, - }, - }, - }, - { - AND: [{ teamId: { not: null } }, { teamId: { in: userTeamIds } }], - }, - { - userId: userId, - }, - ]; - - const orgAdminConditions = []; - if (isUserOrganizationAdmin && currentOrganizationId) { - const organizationUsersEventTypesQuery = { + return await this.prismaClient.eventType.findFirst({ + where: { AND: [ - { userId: { not: null } }, { - owner: { - profiles: { - some: { - organizationId: currentOrganizationId, + OR: [ + { + users: { + some: { + id: userId, + }, }, }, - }, + { + AND: [{ teamId: { not: null } }, { teamId: { in: userTeamIds } }], + }, + { + userId: userId, + }, + ], }, - ], - }; - const organizationTeamsEventTypesQuery = { - AND: [ - { teamId: { not: null } }, { - team: { - parentId: currentOrganizationId, - }, + id, }, ], - }; - orgAdminConditions.push(organizationUsersEventTypesQuery); - orgAdminConditions.push(organizationTeamsEventTypesQuery); - } + }, + select: findByIdCompleteEventTypeSelect, + }); + } + + async findByIdForOrgAdmin({ id, organizationId }: { id: number; organizationId: number }) { + const orgUserEventTypeQuery = { + AND: [{ userId: { not: null } }, { owner: { profiles: { some: { organizationId } } } }], + }; + const orgTeamEventTypeQuery = { + AND: [{ teamId: { not: null } }, { team: { parentId: organizationId } }], + }; return await this.prismaClient.eventType.findFirst({ where: { AND: [ + { id }, { - OR: [...eventTypeOwnerAccessConditions, ...orgAdminConditions], - }, - { - id, + OR: [orgUserEventTypeQuery, orgTeamEventTypeQuery], }, ], }, - select: CompleteEventTypeSelect, + select: findByIdCompleteEventTypeSelect, }); } From b1549ce77d6069094ea6b1ec5124cd49682b658a Mon Sep 17 00:00:00 2001 From: supalarry Date: Wed, 23 Jul 2025 13:31:05 +0200 Subject: [PATCH 09/13] add test --- .../lib/event-types/getEventTypeById.test.ts | 327 ++++++++++++++++++ packages/lib/event-types/getEventTypeById.ts | 2 +- 2 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 packages/lib/event-types/getEventTypeById.test.ts diff --git a/packages/lib/event-types/getEventTypeById.test.ts b/packages/lib/event-types/getEventTypeById.test.ts new file mode 100644 index 00000000000000..2ceebe28cb310a --- /dev/null +++ b/packages/lib/event-types/getEventTypeById.test.ts @@ -0,0 +1,327 @@ +import prismock from "../../../tests/libs/__mocks__/prisma"; + +import { mockNoTranslations } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +import { describe, test, expect, beforeEach, vi } from "vitest"; + +import { getRawEventType } from "./getEventTypeById"; + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: (key: string) => () => key, +})); + +describe("getRawEventType", () => { + beforeEach(() => { + mockNoTranslations(); + }); + + describe("Regular user access", () => { + test("should fetch event type when user owns it", async () => { + const user = await prismock.user.create({ + data: { + username: "testuser", + email: "testuser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Test Event Type", + slug: "test-event", + length: 30, + userId: user.id, + users: { + connect: [{ id: user.id }], + }, + }, + include: { + users: true, + }, + }); + + const result = await getRawEventType({ + userId: user.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: false, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Test Event Type"); + expect(result?.userId).toBe(user.id); + }); + + test.skip("should return null when user doesn't have access to event type", async () => { + // note(Lauris): test skipped because somehow when creating event type eventType.users includes otherUser + const owner = await prismock.user.create({ + data: { + username: "owner", + email: "owner1@example.com", + }, + }); + + const otherUser = await prismock.user.create({ + data: { + username: "otheruser", + email: "otheruser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Owner's Event Type", + slug: "owner-event", + length: 30, + userId: owner.id, + users: { + connect: [{ id: owner.id }], + }, + }, + select: { + id: true, + userId: true, + users: true, + }, + }); + + await prismock.user.update({ + where: { + id: otherUser.id, + }, + data: { + eventTypes: { + disconnect: [{ id: eventType.id }], + }, + }, + }); + + const result = await getRawEventType({ + userId: otherUser.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: false, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Organization admin access", () => { + test("should fetch team event type when user is org admin", async () => { + const organization = await prismock.team.create({ + data: { + id: 100, + name: "Test Organization", + slug: "test-org", + isOrganization: true, + }, + }); + + const team = await prismock.team.create({ + data: { + id: 200, + name: "Test Team", + slug: "test-team", + parentId: organization.id, + }, + }); + + const orgAdmin = await prismock.user.create({ + data: { + username: "orgadmin", + email: "orgadmin@example.com", + organizationId: organization.id, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Team Event Type", + slug: "team-event", + length: 30, + teamId: team.id, + }, + include: { + team: true, + users: true, + }, + }); + + const result = await getRawEventType({ + userId: orgAdmin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: organization.id, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Team Event Type"); + expect(result?.teamId).toBe(team.id); + }); + + test("should fetch user event type when user is org admin and user belongs to org", async () => { + const organization = await prismock.team.create({ + data: { + id: 101, + name: "Test Organization 2", + slug: "test-org-2", + isOrganization: true, + }, + }); + + const orgAdmin = await prismock.user.create({ + data: { + username: "orgadmin2", + email: "orgadmin2@example.com", + organizationId: organization.id, + }, + }); + + const orgUser = await prismock.user.create({ + data: { + username: "orguser", + email: "orguser@example.com", + organizationId: organization.id, + profiles: { + create: { + organizationId: organization.id, + uid: "orguser", + username: "orguser", + }, + }, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Org User Event Type", + slug: "org-user-event", + length: 30, + userId: orgUser.id, + users: { + connect: [{ id: orgUser.id }], + }, + }, + include: { + users: true, + owner: true, + }, + }); + + const result = await getRawEventType({ + userId: orgAdmin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: organization.id, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.title).toBe("Org User Event Type"); + expect(result?.userId).toBe(orgUser.id); + }); + + test("should return null when org admin tries to access event type from different org", async () => { + const org1 = await prismock.team.create({ + data: { + id: 102, + name: "Organization 1", + slug: "org-1", + isOrganization: true, + }, + }); + + const org2 = await prismock.team.create({ + data: { + id: 103, + name: "Organization 2", + slug: "org-2", + isOrganization: true, + }, + }); + + const team1 = await prismock.team.create({ + data: { + id: 201, + name: "Team in Org 1", + slug: "team-org-1", + parentId: org1.id, + }, + }); + + const org2Admin = await prismock.user.create({ + data: { + username: "org2admin", + email: "org2admin@example.com", + organizationId: org2.id, + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Org 1 Team Event", + slug: "org1-team-event", + length: 30, + teamId: team1.id, + }, + include: { + team: true, + users: true, + }, + }); + + const result = await getRawEventType({ + userId: org2Admin.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: org2.id, + prisma: prismock as any, + }); + + expect(result).toBeNull(); + }); + + test("should fallback to regular user access when org admin flag is true but no organizationId", async () => { + const user = await prismock.user.create({ + data: { + username: "regularuser", + email: "regularuser@example.com", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + title: "Regular User Event", + slug: "regular-user-event", + length: 30, + userId: user.id, + users: { + connect: [{ id: user.id }], + }, + }, + include: { + users: true, + owner: true, + }, + }); + + const result = await getRawEventType({ + userId: user.id, + eventTypeId: eventType.id, + isUserOrganizationAdmin: true, + currentOrganizationId: null, + prisma: prismock as any, + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe(eventType.id); + expect(result?.userId).toBe(user.id); + }); + }); +}); diff --git a/packages/lib/event-types/getEventTypeById.ts b/packages/lib/event-types/getEventTypeById.ts index c8769b0251be06..49d0e967a787b2 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -264,7 +264,7 @@ export const getEventTypeById = async ({ return finalObj; }; -async function getRawEventType({ +export async function getRawEventType({ userId, eventTypeId, isUserOrganizationAdmin, From 571130916b9cd016e0f4444d6724da1a038f80b2 Mon Sep 17 00:00:00 2001 From: supalarry Date: Wed, 23 Jul 2025 16:15:57 +0200 Subject: [PATCH 10/13] chore: libraries --- apps/api/v2/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 319b4bb4010103..f1a139860b4645 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.264", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.265", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/yarn.lock b/yarn.lock index c35d5ba909d673..e94a3114ef0b88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,7 +2519,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.264" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.265" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3566,13 +3566,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.264": - version: 0.0.264 - resolution: "@calcom/platform-libraries@npm:0.0.264" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.265": + version: 0.0.265 + resolution: "@calcom/platform-libraries@npm:0.0.265" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: db9e9e0caf7b2827c00db7f70d744063069b52db1b5e778c7495c1bbb910fdcb6d16d8e44d44e84437d3cc8da3aebbfadeb53f0d9072fc02c514f5327e671de0 + checksum: b4ee0c53f052fddd70ef48ff413f6ff900a55ea602dccd87ef118b5e892ac9e92c12e87ce820040a768b57d0327977ea92f3d756dddb323c16e9b5b74c4c9382 languageName: node linkType: hard From 0e38d956d9b45b2bd66d15cd9102a85ebe896888 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 28 Jul 2025 11:53:20 +0200 Subject: [PATCH 11/13] reset eventTypeRepository to main --- .../server/repository/eventTypeRepository.ts | 575 +++++++++--------- 1 file changed, 302 insertions(+), 273 deletions(-) diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index c2debf928a6624..fa3f8ff80b4e76 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -19,6 +19,17 @@ import type { UserWithLegacySelectedCalendars } from "./user"; import { withSelectedCalendars } from "./user"; const log = logger.getSubLogger({ prefix: ["repository/eventType"] }); + +const hashedLinkSelect = { + select: { + id: true, + link: true, + expiresAt: true, + maxUsageCount: true, + usageCount: true, + }, +}; + type NotSupportedProps = "locations"; type IEventType = Ensure< Partial< @@ -50,6 +61,7 @@ const userSelect = { avatarUrl: true, username: true, id: true, + timeZone: true, } satisfies Prisma.UserSelect; function hostsWithSelectedCalendars( @@ -68,254 +80,6 @@ function usersWithSelectedCalendars< return users.map((user) => withSelectedCalendars(user)); } -const findByIdUserSelect = { - name: true, - avatarUrl: true, - username: true, - id: true, - email: true, - locale: true, - defaultScheduleId: true, - isPlatformManaged: true, -} satisfies Prisma.UserSelect; - -const findByIdCompleteEventTypeSelect = { - id: true, - title: true, - slug: true, - description: true, - interfaceLanguage: true, - length: true, - isInstantEvent: true, - instantMeetingExpiryTimeOffsetInSeconds: true, - instantMeetingParameters: true, - aiPhoneCallConfig: true, - offsetStart: true, - hidden: true, - locations: true, - eventName: true, - customInputs: true, - timeZone: true, - periodType: true, - metadata: true, - periodDays: true, - periodStartDate: true, - periodEndDate: true, - periodCountCalendarDays: true, - lockTimeZoneToggleOnBookingPage: true, - requiresConfirmation: true, - requiresConfirmationForFreeEmail: true, - canSendCalVideoTranscriptionEmails: true, - requiresConfirmationWillBlockSlot: true, - requiresBookerEmailVerification: true, - autoTranslateDescriptionEnabled: true, - fieldTranslations: { - select: { - translatedText: true, - targetLocale: true, - field: true, - }, - }, - recurringEvent: true, - hideCalendarNotes: true, - hideCalendarEventDetails: true, - disableGuests: true, - disableCancelling: true, - disableRescheduling: true, - allowReschedulingCancelledBookings: true, - minimumBookingNotice: true, - beforeEventBuffer: true, - afterEventBuffer: true, - slotInterval: true, - hashedLink: true, - eventTypeColor: true, - bookingLimits: true, - onlyShowFirstAvailableSlot: true, - durationLimits: true, - maxActiveBookingsPerBooker: true, - maxActiveBookingPerBookerOfferReschedule: true, - assignAllTeamMembers: true, - allowReschedulingPastBookings: true, - hideOrganizerEmail: true, - assignRRMembersUsingSegment: true, - rrSegmentQueryValue: true, - isRRWeightsEnabled: true, - rescheduleWithSameRoundRobinHost: true, - successRedirectUrl: true, - forwardParamsSuccessRedirect: true, - currency: true, - bookingFields: true, - useEventTypeDestinationCalendarEmail: true, - customReplyToEmail: true, - owner: { - select: { - id: true, - }, - }, - parent: { - select: { - id: true, - teamId: true, - }, - }, - teamId: true, - team: { - select: { - id: true, - name: true, - slug: true, - parentId: true, - rrTimestampBasis: true, - parent: { - select: { - slug: true, - organizationSettings: { - select: { - lockEventTypeCreationForUsers: true, - }, - }, - }, - }, - members: { - select: { - role: true, - accepted: true, - user: { - select: { - ...findByIdUserSelect, - eventTypes: { - select: { - slug: true, - }, - }, - }, - }, - }, - }, - }, - }, - restrictionScheduleId: true, - useBookerTimezone: true, - users: { - select: findByIdUserSelect, - }, - schedulingType: true, - schedule: { - select: { - id: true, - name: true, - }, - }, - instantMeetingSchedule: { - select: { - id: true, - name: true, - }, - }, - restrictionSchedule: { - select: { - id: true, - name: true, - }, - }, - hosts: { - select: { - isFixed: true, - userId: true, - priority: true, - weight: true, - scheduleId: true, - }, - }, - userId: true, - price: true, - children: { - select: { - owner: { - select: { - avatarUrl: true, - name: true, - username: true, - email: true, - id: true, - }, - }, - hidden: true, - slug: true, - }, - }, - destinationCalendar: true, - seatsPerTimeSlot: true, - seatsShowAttendees: true, - seatsShowAvailabilityCount: true, - webhooks: { - select: { - id: true, - subscriberUrl: true, - payloadTemplate: true, - active: true, - eventTriggers: true, - secret: true, - eventTypeId: true, - }, - }, - workflows: { - include: { - workflow: { - select: { - name: true, - id: true, - trigger: true, - time: true, - timeUnit: true, - userId: true, - teamId: true, - team: { - select: { - id: true, - slug: true, - name: true, - members: true, - }, - }, - activeOn: { - select: { - eventType: { - select: { - id: true, - title: true, - parentId: true, - _count: { - select: { - children: true, - }, - }, - }, - }, - }, - }, - steps: true, - }, - }, - }, - }, - secondaryEmailId: true, - maxLeadThreshold: true, - includeNoShowInRRCalculation: true, - useEventLevelSelectedCalendars: true, - calVideoSettings: { - select: { - disableRecordingForGuests: true, - disableRecordingForOrganizer: true, - enableAutomaticTranscription: true, - enableAutomaticRecordingForOrganizer: true, - disableTranscriptionForGuests: true, - disableTranscriptionForOrganizer: true, - redirectUrlOnExit: true, - }, - }, -} satisfies Prisma.EventTypeSelect; - export class EventTypeRepository { constructor(private prismaClient: PrismaClient) {} @@ -407,7 +171,7 @@ export class EventTypeRepository { const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, users: { select: userSelect }, children: { include: { @@ -419,6 +183,21 @@ export class EventTypeRepository { user: { select: userSelect }, }, }, + team: { + select: { + id: true, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, }; log.debug( @@ -522,7 +301,7 @@ export class EventTypeRepository { const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, }; log.debug( @@ -629,11 +408,12 @@ export class EventTypeRepository { avatarUrl: true, username: true, id: true, + timeZone: true, } satisfies Prisma.UserSelect; const select = { ...eventTypeSelect, - hashedLink: true, + hashedLink: hashedLinkSelect, users: { select: userSelect, take: 5 }, children: { include: { @@ -646,6 +426,21 @@ export class EventTypeRepository { }, take: 5, }, + team: { + select: { + id: true, + members: { + select: { + user: { + select: { + timeZone: true, + }, + }, + }, + take: 1, + }, + }, + }, }; const teamMembership = await this.prismaClient.membership.findFirst({ @@ -717,6 +512,261 @@ export class EventTypeRepository { } async findById({ id, userId }: { id: number; userId: number }) { + const userSelect = { + name: true, + avatarUrl: true, + username: true, + id: true, + email: true, + locale: true, + defaultScheduleId: true, + isPlatformManaged: true, + timeZone: true, + } satisfies Prisma.UserSelect; + + const CompleteEventTypeSelect = { + id: true, + title: true, + slug: true, + description: true, + interfaceLanguage: true, + length: true, + isInstantEvent: true, + instantMeetingExpiryTimeOffsetInSeconds: true, + instantMeetingParameters: true, + aiPhoneCallConfig: true, + offsetStart: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + timeZone: true, + periodType: true, + metadata: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + lockTimeZoneToggleOnBookingPage: true, + requiresConfirmation: true, + requiresConfirmationForFreeEmail: true, + canSendCalVideoTranscriptionEmails: true, + requiresConfirmationWillBlockSlot: true, + requiresBookerEmailVerification: true, + autoTranslateDescriptionEnabled: true, + fieldTranslations: { + select: { + translatedText: true, + targetLocale: true, + field: true, + }, + }, + recurringEvent: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + disableGuests: true, + disableCancelling: true, + disableRescheduling: true, + allowReschedulingCancelledBookings: true, + minimumBookingNotice: true, + beforeEventBuffer: true, + afterEventBuffer: true, + slotInterval: true, + hashedLink: hashedLinkSelect, + eventTypeColor: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, + maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, + assignAllTeamMembers: true, + allowReschedulingPastBookings: true, + hideOrganizerEmail: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, + isRRWeightsEnabled: true, + rescheduleWithSameRoundRobinHost: true, + successRedirectUrl: true, + forwardParamsSuccessRedirect: true, + currency: true, + bookingFields: true, + useEventTypeDestinationCalendarEmail: true, + customReplyToEmail: true, + owner: { + select: { + id: true, + timeZone: true, + }, + }, + parent: { + select: { + id: true, + teamId: true, + }, + }, + teamId: true, + team: { + select: { + id: true, + name: true, + slug: true, + parentId: true, + rrTimestampBasis: true, + parent: { + select: { + slug: true, + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, + }, + }, + members: { + select: { + role: true, + accepted: true, + user: { + select: { + ...userSelect, + eventTypes: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + }, + }, + restrictionScheduleId: true, + useBookerTimezone: true, + users: { + select: userSelect, + }, + schedulingType: true, + schedule: { + select: { + id: true, + name: true, + }, + }, + instantMeetingSchedule: { + select: { + id: true, + name: true, + }, + }, + restrictionSchedule: { + select: { + id: true, + name: true, + }, + }, + hosts: { + select: { + isFixed: true, + userId: true, + priority: true, + weight: true, + scheduleId: true, + user: { + select: { + timeZone: true, + }, + }, + }, + }, + userId: true, + price: true, + children: { + select: { + owner: { + select: { + avatarUrl: true, + name: true, + username: true, + email: true, + id: true, + }, + }, + hidden: true, + slug: true, + }, + }, + destinationCalendar: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + webhooks: { + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + eventTypeId: true, + }, + }, + workflows: { + include: { + workflow: { + select: { + name: true, + id: true, + trigger: true, + time: true, + timeUnit: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + parentId: true, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + steps: true, + }, + }, + }, + }, + secondaryEmailId: true, + maxLeadThreshold: true, + includeNoShowInRRCalculation: true, + useEventLevelSelectedCalendars: true, + calVideoSettings: { + select: { + disableRecordingForGuests: true, + disableRecordingForOrganizer: true, + enableAutomaticTranscription: true, + enableAutomaticRecordingForOrganizer: true, + disableTranscriptionForGuests: true, + disableTranscriptionForOrganizer: true, + redirectUrlOnExit: true, + }, + }, + } satisfies Prisma.EventTypeSelect; + // This is more efficient than using a complex join with team.members in the query const userTeamIds = await MembershipRepository.findUserTeamIds({ userId }); @@ -745,28 +795,7 @@ export class EventTypeRepository { }, ], }, - select: findByIdCompleteEventTypeSelect, - }); - } - - async findByIdForOrgAdmin({ id, organizationId }: { id: number; organizationId: number }) { - const orgUserEventTypeQuery = { - AND: [{ userId: { not: null } }, { owner: { profiles: { some: { organizationId } } } }], - }; - const orgTeamEventTypeQuery = { - AND: [{ teamId: { not: null } }, { team: { parentId: organizationId } }], - }; - - return await this.prismaClient.eventType.findFirst({ - where: { - AND: [ - { id }, - { - OR: [orgUserEventTypeQuery, orgTeamEventTypeQuery], - }, - ], - }, - select: findByIdCompleteEventTypeSelect, + select: CompleteEventTypeSelect, }); } From 78971b75e2af3c8b714fa025dd9e75395b274b38 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 28 Jul 2025 12:04:27 +0200 Subject: [PATCH 12/13] eventTypeRepository add admin query --- .../server/repository/eventTypeRepository.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index fa3f8ff80b4e76..1ffa6dbd1d5a44 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -799,6 +799,282 @@ export class EventTypeRepository { }); } + async findByIdForOrgAdmin({ id, organizationId }: { id: number; organizationId: number }) { + const userSelect = { + name: true, + avatarUrl: true, + username: true, + id: true, + email: true, + locale: true, + defaultScheduleId: true, + isPlatformManaged: true, + timeZone: true, + } satisfies Prisma.UserSelect; + + const CompleteEventTypeSelect = { + id: true, + title: true, + slug: true, + description: true, + interfaceLanguage: true, + length: true, + isInstantEvent: true, + instantMeetingExpiryTimeOffsetInSeconds: true, + instantMeetingParameters: true, + aiPhoneCallConfig: true, + offsetStart: true, + hidden: true, + locations: true, + eventName: true, + customInputs: true, + timeZone: true, + periodType: true, + metadata: true, + periodDays: true, + periodStartDate: true, + periodEndDate: true, + periodCountCalendarDays: true, + lockTimeZoneToggleOnBookingPage: true, + requiresConfirmation: true, + requiresConfirmationForFreeEmail: true, + canSendCalVideoTranscriptionEmails: true, + requiresConfirmationWillBlockSlot: true, + requiresBookerEmailVerification: true, + autoTranslateDescriptionEnabled: true, + fieldTranslations: { + select: { + translatedText: true, + targetLocale: true, + field: true, + }, + }, + recurringEvent: true, + hideCalendarNotes: true, + hideCalendarEventDetails: true, + disableGuests: true, + disableCancelling: true, + disableRescheduling: true, + allowReschedulingCancelledBookings: true, + minimumBookingNotice: true, + beforeEventBuffer: true, + afterEventBuffer: true, + slotInterval: true, + hashedLink: hashedLinkSelect, + eventTypeColor: true, + bookingLimits: true, + onlyShowFirstAvailableSlot: true, + durationLimits: true, + maxActiveBookingsPerBooker: true, + maxActiveBookingPerBookerOfferReschedule: true, + assignAllTeamMembers: true, + allowReschedulingPastBookings: true, + hideOrganizerEmail: true, + assignRRMembersUsingSegment: true, + rrSegmentQueryValue: true, + isRRWeightsEnabled: true, + rescheduleWithSameRoundRobinHost: true, + successRedirectUrl: true, + forwardParamsSuccessRedirect: true, + currency: true, + bookingFields: true, + useEventTypeDestinationCalendarEmail: true, + customReplyToEmail: true, + owner: { + select: { + id: true, + timeZone: true, + }, + }, + parent: { + select: { + id: true, + teamId: true, + }, + }, + teamId: true, + team: { + select: { + id: true, + name: true, + slug: true, + parentId: true, + rrTimestampBasis: true, + parent: { + select: { + slug: true, + organizationSettings: { + select: { + lockEventTypeCreationForUsers: true, + }, + }, + }, + }, + members: { + select: { + role: true, + accepted: true, + user: { + select: { + ...userSelect, + eventTypes: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + }, + }, + restrictionScheduleId: true, + useBookerTimezone: true, + users: { + select: userSelect, + }, + schedulingType: true, + schedule: { + select: { + id: true, + name: true, + }, + }, + instantMeetingSchedule: { + select: { + id: true, + name: true, + }, + }, + restrictionSchedule: { + select: { + id: true, + name: true, + }, + }, + hosts: { + select: { + isFixed: true, + userId: true, + priority: true, + weight: true, + scheduleId: true, + user: { + select: { + timeZone: true, + }, + }, + }, + }, + userId: true, + price: true, + children: { + select: { + owner: { + select: { + avatarUrl: true, + name: true, + username: true, + email: true, + id: true, + }, + }, + hidden: true, + slug: true, + }, + }, + destinationCalendar: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, + seatsShowAvailabilityCount: true, + webhooks: { + select: { + id: true, + subscriberUrl: true, + payloadTemplate: true, + active: true, + eventTriggers: true, + secret: true, + eventTypeId: true, + }, + }, + workflows: { + include: { + workflow: { + select: { + name: true, + id: true, + trigger: true, + time: true, + timeUnit: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + slug: true, + name: true, + members: true, + }, + }, + activeOn: { + select: { + eventType: { + select: { + id: true, + title: true, + parentId: true, + _count: { + select: { + children: true, + }, + }, + }, + }, + }, + }, + steps: true, + }, + }, + }, + }, + secondaryEmailId: true, + maxLeadThreshold: true, + includeNoShowInRRCalculation: true, + useEventLevelSelectedCalendars: true, + calVideoSettings: { + select: { + disableRecordingForGuests: true, + disableRecordingForOrganizer: true, + enableAutomaticTranscription: true, + enableAutomaticRecordingForOrganizer: true, + disableTranscriptionForGuests: true, + disableTranscriptionForOrganizer: true, + redirectUrlOnExit: true, + }, + }, + } satisfies Prisma.EventTypeSelect; + + const orgUserEventTypeQuery = { + AND: [{ userId: { not: null } }, { owner: { profiles: { some: { organizationId } } } }], + }; + const orgTeamEventTypeQuery = { + AND: [{ teamId: { not: null } }, { team: { parentId: organizationId } }], + }; + + return await this.prismaClient.eventType.findFirst({ + where: { + AND: [ + { id }, + { + OR: [orgUserEventTypeQuery, orgTeamEventTypeQuery], + }, + ], + }, + select: CompleteEventTypeSelect, + }); + } + async findByIdMinimal({ id }: { id: number }) { return await this.prismaClient.eventType.findUnique({ where: { From e4826220f99dd4561d3c74e0b1e3334efd5461a9 Mon Sep 17 00:00:00 2001 From: supalarry Date: Mon, 28 Jul 2025 12:07:22 +0200 Subject: [PATCH 13/13] chore: bump platform libraries --- apps/api/v2/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 87c2b98682e4c0..faffb6192c4416 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/yarn.lock b/yarn.lock index 7a6dbca9284ba1..aae64608f91b95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,7 +2519,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3566,13 +3566,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.267": - version: 0.0.267 - resolution: "@calcom/platform-libraries@npm:0.0.267" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.268": + version: 0.0.268 + resolution: "@calcom/platform-libraries@npm:0.0.268" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 0c385514a04ddbd96ab3277774815233801a6eaf9a0d406b473eb7a446602f6f7d98de7f16747893b9fe1d50257db13b050a5726c8e7fe9500750d3ebf7be23a + checksum: d361c067dd33e3807c3ba965b3582eadaeec2ef1e2a3114c68e0c2ab1d7149fcca2c72ce00f8f3f341bdf3caeca578898d3345d8615c58da3a66e541e62d2ca5 languageName: node linkType: hard