From 995bf09ddec3177c9e8795342ce64553d119bba9 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Mon, 21 Oct 2024 10:59:07 +0300 Subject: [PATCH] fix: eventType atom team availability tab (#17158) Co-authored-by: Somay Chauhan --- .../guards/users/is-user-in-org-team.guard.ts | 42 ++++ ...s-teams-memberships.controller.e2e-spec.ts | 17 +- ...rganizations-teams-schedules.controller.ts | 43 ++++ .../organizations-teams-schedules.e2e-spec.ts | 227 ++++++++++++++++++ .../organizations/organizations.module.ts | 2 + .../organizations/organizations.repository.ts | 18 ++ .../organization-teams-memberships.output.ts | 26 ++ ...anizations-teams-memberships.repository.ts | 6 +- apps/api/v2/swagger/documentation.json | 58 ++++- .../components/CheckedTeamSelect.tsx | 5 +- .../availability/EventAvailabilityTab.tsx | 33 ++- .../atoms/cal-provider/BaseCalProvider.tsx | 1 + .../event-types/hooks/useAtomHostSchedules.ts | 36 +++ .../EventAvailabilityTabPlatformWrapper.tsx | 14 ++ .../wrappers/EventTypePlatformWrapper.tsx | 7 +- .../atoms/hooks/teams/useTeamMembers.ts | 34 +++ .../platform/atoms/hooks/useAtomsContext.ts | 1 + 17 files changed, 553 insertions(+), 17 deletions(-) create mode 100644 apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts create mode 100644 packages/platform/atoms/event-types/hooks/useAtomHostSchedules.ts create mode 100644 packages/platform/atoms/hooks/teams/useTeamMembers.ts diff --git a/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts new file mode 100644 index 0000000000..12bcaee592 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/users/is-user-in-org-team.guard.ts @@ -0,0 +1,42 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserInOrgTeam implements CanActivate { + constructor(private organizationsRepository: OrganizationsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const teamId: string = request.params.teamId; + const orgId: string = request.params.orgId; + const userId: string = request.params.userId; + + if (!userId) { + throw new ForbiddenException("No user id found in request params."); + } + + if (!orgId) { + throw new ForbiddenException("No org id found in request params."); + } + + if (!teamId) { + throw new ForbiddenException("No team id found in request params."); + } + + const user = await this.organizationsRepository.findOrgTeamUser( + Number(orgId), + Number(teamId), + Number(userId) + ); + + if (user) { + request.user = user; + return true; + } + + return false; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts index 769a3ca3fc..f03a0affdb 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts @@ -2,6 +2,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input"; import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input"; +import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -37,7 +38,7 @@ describe("Organizations Teams Memberships Endpoints", () => { let nonOrgTeam: Team; let membership: Membership; let membership2: Membership; - let membershipCreatedViaApi: Membership; + let membershipCreatedViaApi: OrgTeamMembershipOutputDto; const userEmail = "org-admin-membership-teams-controller-e2e@api.com"; const userEmail2 = "org-member-membership-teams-controller-e2e@api.com"; @@ -200,7 +201,7 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership.id); expect(responseBody.data[1].id).toEqual(membership2.id); @@ -219,7 +220,7 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership2.id); expect(responseBody.data[0].userId).toEqual(user2.id); @@ -238,10 +239,11 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membership.id); expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.user.email).toEqual(user.email); }); }); @@ -272,12 +274,13 @@ describe("Organizations Teams Memberships Endpoints", () => { } satisfies CreateOrgTeamMembershipDto) .expect(201) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id); expect(membershipCreatedViaApi.role).toEqual("MEMBER"); expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); + expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email); }); }); @@ -300,7 +303,7 @@ describe("Organizations Teams Memberships Endpoints", () => { } satisfies UpdateOrgTeamMembershipDto) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.role).toEqual("OWNER"); @@ -312,7 +315,7 @@ describe("Organizations Teams Memberships Endpoints", () => { .delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); }); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts new file mode 100644 index 0000000000..cf826f497e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller.ts @@ -0,0 +1,43 @@ +import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +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 { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard"; +import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/teams/:teamId", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Teams / Schedules") +export class OrganizationsTeamsSchedulesController { + constructor(private schedulesService: SchedulesService_2024_06_11) {} + + @Roles("TEAM_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsUserInOrgTeam) + @Get("/users/:userId/schedules") + @DocsTags("Orgs / Teams / Users / Schedules") + @ApiOperation({ summary: "Get schedules of a team member" }) + async getUserSchedules( + @Param("userId", ParseIntPipe) userId: number + ): Promise { + const schedules = await this.schedulesService.getUserSchedules(userId); + + return { + status: SUCCESS_STATUS, + data: schedules, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts new file mode 100644 index 0000000000..cf6dbb5972 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.e2e-spec.ts @@ -0,0 +1,227 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types"; +import { Team, Schedule } from "@calcom/prisma/client"; + +describe("Organizations Teams Schedules Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let orgTeam: Team; + let nonOrgTeam: Team; + let user2Schedule: Schedule; + + const userEmail = "org-admin-schedules-teams-controller-e2e@api.com"; + const userEmail2 = "org-member-schedules-teams-controller-e2e@api.com"; + const nonOrgUserEmail = "non-org-member-schedules-teams-controller-e2e@api.com"; + + const invitedUserEmail = "org-member-invited-schedules-teams-controller-e2e@api.com"; + + let user: User; + let user2: User; + let nonOrgUser: User; + + let userToInviteViaApi: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + }); + + user2Schedule = await scheduleRepositoryFixture.create({ + user: { + connect: { + id: user2.id, + }, + }, + name: "User 2 schedule", + timeZone: "America/New_York", + }); + + nonOrgUser = await userRepositoryFixture.create({ + email: nonOrgUserEmail, + username: nonOrgUserEmail, + }); + + userToInviteViaApi = await userRepositoryFixture.create({ + email: invitedUserEmail, + username: invitedUserEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization", + isOrganization: true, + }); + + orgTeam = await teamsRepositoryFixture.create({ + name: "Org Team", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + nonOrgTeam = await teamsRepositoryFixture.create({ + name: "Non Org Team", + isOrganization: false, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: orgTeam.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user2.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: userToInviteViaApi.id } }, + team: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: nonOrgUser.id } }, + team: { connect: { id: nonOrgTeam.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: userEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user2.id}`, + username: userEmail2, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user2.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${userToInviteViaApi.id}`, + username: invitedUserEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: userToInviteViaApi.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get all the schedule of the org's team's member", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.find((d) => d.id === user2Schedule.id)?.name).toEqual(user2Schedule.name); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await organizationsRepositoryFixture.delete(org.id); + await teamsRepositoryFixture.delete(nonOrgTeam.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index dc39adb9de..db6d9c695a 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -11,6 +11,7 @@ import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/contro import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller"; import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; +import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller"; import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; @@ -112,6 +113,7 @@ import { Module } from "@nestjs/common"; OrganizationsAttributesController, OrganizationsOptionsAttributesController, OrganizationsWebhooksController, + OrganizationsTeamsSchedulesController, ], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts index b5b8af6273..ecfa298ab3 100644 --- a/apps/api/v2/src/modules/organizations/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -103,6 +103,24 @@ export class OrganizationsRepository { }); } + async findOrgTeamUser(organizationId: number, teamId: number, userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + profiles: { + some: { + organizationId, + }, + }, + teams: { + some: { + teamId: teamId, + }, + }, + }, + }); + } + async fetchOrgAdminApiStatus(organizationId: number) { return this.dbRead.prisma.organizationSettings.findUnique({ where: { diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts index ceff7066cc..a6d3efdf18 100644 --- a/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts +++ b/apps/api/v2/src/modules/organizations/outputs/organization-teams-memberships.output.ts @@ -5,6 +5,27 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +export class MembershipUserOutputDto { + @IsOptional() + @IsString() + @Expose() + readonly avatarUrl?: string; + + @IsOptional() + @IsString() + @Expose() + readonly username?: string; + + @IsOptional() + @IsString() + @Expose() + readonly name?: string; + + @IsBoolean() + @Expose() + readonly email!: string; +} + export class OrgTeamMembershipOutputDto { @IsInt() @Expose() @@ -31,6 +52,11 @@ export class OrgTeamMembershipOutputDto { @IsBoolean() @Expose() readonly disableImpersonation?: boolean; + + @ValidateNested() + @Type(() => MembershipUserOutputDto) + @Expose() + user!: MembershipUserOutputDto; } export class OrgTeamMembershipsOutputResponseDto { diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts index 470bab239f..e75b37ac89 100644 --- a/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams-memberships.repository.ts @@ -9,13 +9,14 @@ export class OrganizationsTeamsMembershipsRepository { constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) { - return this.dbRead.prisma.membership.findMany({ + return await this.dbRead.prisma.membership.findMany({ where: { teamId: teamId, team: { parentId: organizationId, }, }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, skip, take, }); @@ -30,6 +31,7 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, }); } async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) { @@ -59,12 +61,14 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, }); } async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { return this.dbWrite.prisma.membership.create({ data: { ...data, teamId: teamId }, + include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, }); } } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 068fc993b5..062e80206d 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -2412,6 +2412,38 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/users/{userId}/schedules": { + "get": { + "operationId": "OrganizationsTeamsSchedulesController_getUserSchedules", + "summary": "Get schedules of a team member", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Schedules", + "Orgs / Teams / Users / Schedules" + ] + } + }, "/v2/organizations/{orgId}/users": { "get": { "operationId": "OrganizationsUsersController_getOrganizationsUsers", @@ -10447,6 +10479,26 @@ "data" ] }, + "MembershipUserOutputDto": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "email" + ] + }, "OrgTeamMembershipOutputDto": { "type": "object", "properties": { @@ -10472,6 +10524,9 @@ }, "disableImpersonation": { "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/MembershipUserOutputDto" } }, "required": [ @@ -10479,7 +10534,8 @@ "id", "userId", "teamId", - "accepted" + "accepted", + "user" ] }, "OrgTeamMembershipsOutputResponseDto": { diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index 690798d8a3..ee5149a927 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -2,6 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useState } from "react"; import type { Props } from "react-select"; +import { useIsPlatform } from "@calcom/atoms/monorepo"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Avatar, Button, Icon, Select, Tooltip } from "@calcom/ui"; @@ -30,6 +31,7 @@ export const CheckedTeamSelect = ({ onChange: (value: readonly CheckedSelectOption[]) => void; isRRWeightsEnabled?: boolean; }) => { + const isPlatform = useIsPlatform(); const [priorityDialogOpen, setPriorityDialogOpen] = useState(false); const [weightDialogOpen, setWeightDialogOpen] = useState(false); @@ -59,7 +61,8 @@ export const CheckedTeamSelect = ({
  • - + {!isPlatform && } + {isPlatform && }

    {option.label}

    {option && !option.isFixed ? ( diff --git a/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx index 0f4e34b4e3..3d0957daa3 100644 --- a/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx +++ b/packages/features/eventtypes/components/tabs/availability/EventAvailabilityTab.tsx @@ -1,10 +1,12 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; +import type { UseQueryResult } from "@tanstack/react-query"; import { useState, memo, useEffect } from "react"; import { Controller, useFormContext } from "react-hook-form"; import type { OptionProps, SingleValueProps } from "react-select"; import { components } from "react-select"; import type { GetAllSchedulesByUserIdQueryType } from "@calcom/atoms/event-types/wrappers/EventAvailabilityTabWebWrapper"; +import { useIsPlatform } from "@calcom/atoms/monorepo"; import dayjs from "@calcom/dayjs"; import { SelectSkeletonLoader } from "@calcom/features/availability/components/SkeletonLoader"; import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; @@ -35,18 +37,35 @@ type EventTypeScheduleDetailsProps = { editAvailabilityRedirectUrl?: string; }; +type HostSchedulesQueryType = + | GetAllSchedulesByUserIdQueryType + | (({ userId }: { userId: number }) => UseQueryResult< + { + schedules: { + id: number; + name: string; + isDefault: boolean; + userId: number; + readOnly: boolean; + }[]; + }, + Error + >); + type EventTypeTeamScheduleProps = { - hostSchedulesQuery: GetAllSchedulesByUserIdQueryType; + hostSchedulesQuery: HostSchedulesQueryType; hosts?: Host[]; }; +type TeamMember = Pick; + type EventTypeScheduleProps = { schedulesQueryData?: Array< Omit >; isSchedulesPending?: boolean; eventType: EventTypeSetup; - teamMembers: TeamMembers; + teamMembers: TeamMember[]; } & EventTypeScheduleDetailsProps & EventTypeTeamScheduleProps; @@ -302,10 +321,11 @@ const TeamMemberSchedule = ({ }: { host: Host; index: number; - teamMembers: TeamMembers; - hostScheduleQuery: GetAllSchedulesByUserIdQueryType; + teamMembers: TeamMember[]; + hostScheduleQuery: HostSchedulesQueryType; }) => { const { t } = useLocale(); + const isPlatform = useIsPlatform(); const formMethods = useFormContext(); const { getValues } = formMethods; @@ -337,7 +357,8 @@ const TeamMemberSchedule = ({ return ( <>
    - + {!isPlatform && } + {isPlatform && }

    {label}

    @@ -374,7 +395,7 @@ const TeamAvailability = ({ hosts, teamMembers, hostSchedulesQuery, -}: EventTypeTeamScheduleProps & { teamMembers: TeamMembers }) => { +}: EventTypeTeamScheduleProps & { teamMembers: TeamMember[] }) => { const { t } = useLocale(); const [animationRef] = useAutoAnimate(); diff --git a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx index 548b20ae23..c29bd90949 100644 --- a/packages/platform/atoms/cal-provider/BaseCalProvider.tsx +++ b/packages/platform/atoms/cal-provider/BaseCalProvider.tsx @@ -151,6 +151,7 @@ export function BaseCalProvider({ isValidClient: Boolean(!error && clientId && isInit), isAuth: Boolean(isInit && !error && clientId && currentAccessToken && http.getAuthorizationHeader()), organizationId: organizationId || stateOrgId || me?.data.organizationId || 0, + userId: me?.data.id, ...translations, }}> {children} diff --git a/packages/platform/atoms/event-types/hooks/useAtomHostSchedules.ts b/packages/platform/atoms/event-types/hooks/useAtomHostSchedules.ts new file mode 100644 index 0000000000..8386ac4a90 --- /dev/null +++ b/packages/platform/atoms/event-types/hooks/useAtomHostSchedules.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types"; + +import { useAtomsContext } from "../../hooks/useAtomsContext"; +import http from "../../lib/http"; + +export const QUERY_KEY = "use-atom-host-schedule-by-id"; + +export const useAtomHostSchedules = ({ userId, teamId }: { userId: number; teamId?: number }) => { + const { organizationId, userId: meId } = useAtomsContext(); + + const event = useQuery({ + queryKey: [QUERY_KEY, organizationId, userId], + queryFn: async () => { + const pathname = `/organizations/${organizationId}/teams/${teamId}/users/${userId}/schedules`; + return http?.get(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + const schedules = res.data.data.map((schedule) => ({ + id: schedule.id, + name: schedule.name, + isDefault: schedule.isDefault, + userId: schedule.ownerId, + readOnly: meId !== userId, + })); + return { schedules }; + } + throw new Error(res.data?.error?.message ?? "Error while getting host schedules"); + }); + }, + enabled: !!organizationId && !!teamId && !!userId, + }); + + return event; +}; diff --git a/packages/platform/atoms/event-types/wrappers/EventAvailabilityTabPlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventAvailabilityTabPlatformWrapper.tsx index d516912d38..c43e85dd93 100644 --- a/packages/platform/atoms/event-types/wrappers/EventAvailabilityTabPlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventAvailabilityTabPlatformWrapper.tsx @@ -6,6 +6,8 @@ import type { User } from "@calcom/prisma/client"; import type { Availability } from "../../availability/AvailabilitySettings"; import { transformApiScheduleForAtom } from "../../availability/atom-api-transformers/transformApiScheduleForAtom"; +import { useTeamMembers } from "../../hooks/teams/useTeamMembers"; +import { useAtomHostSchedules } from "../hooks/useAtomHostSchedules"; import { useSchedule } from "../hooks/useSchedule"; import { useSchedules } from "../hooks/useSchedules"; @@ -13,10 +15,12 @@ type EventAvailabilityTabPlatformWrapperProps = { user?: Pick; eventType: EventTypeSetup; isTeamEvent: boolean; + teamId?: number; }; const EventAvailabilityTabPlatformWrapper = ({ user, + teamId, ...props }: EventAvailabilityTabPlatformWrapperProps) => { const formMethods = useFormContext(); @@ -29,6 +33,8 @@ const EventAvailabilityTabPlatformWrapper = ({ const { data: schedulesQueryData, isLoading: isSchedulesPending } = useSchedules(); const atomSchedule = transformApiScheduleForAtom(user, scheduleQueryData, schedulesQueryData?.length || 0); + const hostSchedulesQuery = useAtomHostSchedules; + const { data: teamMembers } = useTeamMembers({ teamId }); if (!atomSchedule) { return <>; @@ -38,9 +44,17 @@ const EventAvailabilityTabPlatformWrapper = ({ ({ + avatar: member.user.avatarUrl ?? "", + id: member.userId, + name: member.user.name, + })) ?? [] + } schedulesQueryData={schedulesQueryData} isSchedulesPending={isSchedulesPending} isSchedulePending={isSchedulePending} + hostSchedulesQuery={({ userId }: { userId: number }) => hostSchedulesQuery({ userId, teamId })} scheduleQueryData={{ isManaged: atomSchedule.isManaged, readOnly: atomSchedule.readOnly, diff --git a/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx index 453c2c4413..1519db008b 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypePlatformWrapper.tsx @@ -126,7 +126,12 @@ const EventType = ({ <> ), availability: tabs.includes("availability") ? ( - + ) : ( <> ), diff --git a/packages/platform/atoms/hooks/teams/useTeamMembers.ts b/packages/platform/atoms/hooks/teams/useTeamMembers.ts new file mode 100644 index 0000000000..2cfb07dc54 --- /dev/null +++ b/packages/platform/atoms/hooks/teams/useTeamMembers.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { ApiResponse } from "@calcom/platform-types"; +import type { Membership, User } from "@calcom/prisma/client"; + +import http from "../../lib/http"; +import { useAtomsContext } from "../useAtomsContext"; + +export const QUERY_KEY = "use-team-members"; + +type ResponseDataType = Membership & { user: Pick }; + +export const useTeamMembers = ({ teamId }: { teamId?: number }) => { + const { organizationId } = useAtomsContext(); + + const event = useQuery({ + queryKey: [QUERY_KEY, organizationId, teamId], + queryFn: async () => { + const pathname = `/organizations/${organizationId}/teams/${teamId}/memberships`; + + return http?.get>(pathname).then((res) => { + if (res.data.status === SUCCESS_STATUS) { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: !!organizationId && !!teamId, + }); + + return event; +}; diff --git a/packages/platform/atoms/hooks/useAtomsContext.ts b/packages/platform/atoms/hooks/useAtomsContext.ts index 7a379f63be..41a03c97a2 100644 --- a/packages/platform/atoms/hooks/useAtomsContext.ts +++ b/packages/platform/atoms/hooks/useAtomsContext.ts @@ -29,6 +29,7 @@ export interface IAtomsContext { exists: (key: translationKeys | string) => boolean; }; organizationId: number; + userId?: number; } export const AtomsContext = createContext({