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/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 d5613afabd647b..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 { @@ -88,10 +90,12 @@ export class EventTypesAtomService { throw new NotFoundException(`Event type with id ${eventTypeId} not found`); } - if (eventType?.team?.id) { - await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id); - } else { - this.eventTypeService.checkUserOwnsEventType(user.id, 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 @@ -115,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; @@ -175,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) { 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..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 @@ -27,6 +27,17 @@ export class OrganizationsMembershipService { return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); } + async isOrgAdminOrOwner(organizationId: number, userId: number) { + const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId( + organizationId, + userId + ); + if (!membership) { + return false; + } + 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; 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 ab9f901576ab74..56e7ab28b48c0e 100644 --- a/packages/lib/event-types/getEventTypeById.ts +++ b/packages/lib/event-types/getEventTypeById.ts @@ -53,8 +53,13 @@ export const getEventTypeById = async ({ timeZone: true, } satisfies Prisma.UserSelect; - const eventTypeRepo = new EventTypeRepository(prisma); - const rawEventType = await eventTypeRepo.findById({ id: eventTypeId, userId }); + const rawEventType = await getRawEventType({ + userId, + eventTypeId, + isUserOrganizationAdmin, + currentOrganizationId, + prisma, + }); if (!rawEventType) { if (isTrpcCall) { @@ -260,4 +265,26 @@ export const getEventTypeById = async ({ return finalObj; }; +export 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 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: { 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 })), }), } diff --git a/packages/platform/examples/base/src/pages/event-types.tsx b/packages/platform/examples/base/src/pages/event-types.tsx index 4e929443b3ddce..71118ef3eb8e57 100644 --- a/packages/platform/examples/base/src/pages/event-types.tsx +++ b/packages/platform/examples/base/src/pages/event-types.tsx @@ -743,6 +743,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string onSuccess={(eventType) => { setEventTypeId(null); refetch(); + refetchTeamEvents(); }} onError={(eventType, error) => { console.log(eventType); diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 2ace9298dbf51b..c22294501544c0 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -93,7 +93,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" }); 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