diff --git a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts index d29fe486ea1a36..eb90203845450f 100644 --- a/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/teams/is-team-in-org.guard.ts @@ -1,12 +1,18 @@ -import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; import { Request } from "express"; import { Team } from "@calcom/prisma/client"; @Injectable() export class IsTeamInOrg implements CanActivate { - constructor(private organizationsRepository: OrganizationsRepository) {} + constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -21,13 +27,17 @@ export class IsTeamInOrg implements CanActivate { throw new ForbiddenException("No team id found in request params."); } - const team = await this.organizationsRepository.findOrgTeam(Number(orgId), Number(teamId)); + const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId)); if (team && !team.isOrganization && team.parentId === Number(orgId)) { request.team = team; return true; } + if (!team) { + throw new NotFoundException(`Team (${teamId}) not found.`); + } + return false; } } diff --git a/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.e2e-spec.ts index eb332a420bd719..164c3987736cd9 100644 --- a/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.e2e-spec.ts @@ -1,5 +1,6 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -18,7 +19,7 @@ import { ApiSuccessResponse } from "@calcom/platform-types"; import { Team } from "@calcom/prisma/client"; describe("Organizations Team Endpoints", () => { - describe("User Authentication", () => { + describe("User Authentication - User is Org Admin", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; @@ -28,8 +29,10 @@ describe("Organizations Team Endpoints", () => { let org: Team; let team: Team; + let team2: Team; + let teamCreatedViaApi: Team; - const userEmail = "org-teams-controller-e2e@api.com"; + const userEmail = "org-admin-teams-controller-e2e@api.com"; let user: User; beforeAll(async () => { @@ -67,6 +70,12 @@ describe("Organizations Team Endpoints", () => { parent: { connect: { id: org.id } }, }); + team2 = await teamsRepositoryFixture.create({ + name: "Test org team 2", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -87,7 +96,19 @@ describe("Organizations Team Endpoints", () => { .then((response) => { const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); - expect(responseBody.data).toEqual([]); + expect(responseBody.data[0].id).toEqual(team.id); + expect(responseBody.data[1].id).toEqual(team2.id); + }); + }); + + it("should get all the teams of the org paginated", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams?skip=1&take=1`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data[0].id).toEqual(team2.id); }); }); @@ -107,12 +128,304 @@ describe("Organizations Team Endpoints", () => { }); }); + it("should create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: "Team created via API", + } satisfies CreateOrgTeamDto) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + teamCreatedViaApi = responseBody.data; + expect(teamCreatedViaApi.name).toEqual("Team created via API"); + expect(teamCreatedViaApi.parentId).toEqual(org.id); + }); + }); + + it("should update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .send({ + name: "Team created via API Updated", + } satisfies CreateOrgTeamDto) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + teamCreatedViaApi = responseBody.data; + expect(teamCreatedViaApi.name).toEqual("Team created via API Updated"); + expect(teamCreatedViaApi.parentId).toEqual(org.id); + }); + }); + + it("should delete the team of the org we created via api", async () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.id).toEqual(teamCreatedViaApi.id); + expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId); + }); + }); + + it("should fail to get the team of the org we just deleted", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`) + .expect(404); + }); + it("should fail if the team does not exist", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(403); + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Team Endpoints", () => { + describe("User Authentication - User is Org Member", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let team: Team; + let team2: Team; + + const userEmail = "org-member-teams-controller-e2e@api.com"; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization", + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + team = await teamsRepositoryFixture.create({ + name: "Test org team", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ + name: "Test org team 2", + isOrganization: false, + parent: { connect: { id: org.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 deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); + + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); + + it("should deny get the team of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403); + }); + + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: "Team created via API", + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: "Team created via API Updated", + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); + await organizationsRepositoryFixture.delete(org.id); + await app.close(); + }); + }); +}); + +describe("Organizations Team Endpoints", () => { + describe("User Authentication - User is Team Owner", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + + let org: Team; + let team: Team; + let team2: Team; + + const userEmail = "org-member-teams-owner-controller-e2e@api.com"; + let user: User; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization", + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + team = await teamsRepositoryFixture.create({ + name: "Test org team", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ + name: "Test org team 2", + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team.id } }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team2.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 deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); + + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); + + it("should get the team of the org for which the user is team owner", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200); + }); + + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: "Team created via API", + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: "Team created via API Updated", + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); }); afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); + await teamsRepositoryFixture.delete(team.id); + await teamsRepositoryFixture.delete(team2.id); await organizationsRepositoryFixture.delete(org.id); await app.close(); }); diff --git a/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.ts index c6f5d6a2f149ab..0b329d1dfae0e6 100644 --- a/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/organizations-teams.controller.ts @@ -1,52 +1,106 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; -import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator"; import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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 { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, +} from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { ApiResponse } from "@calcom/platform-types"; +import { ApiResponse, SkipTakePagination } from "@calcom/platform-types"; import { Team } from "@calcom/prisma/client"; @Controller({ path: "/v2/organizations/:orgId/teams", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard) @DocsTags("Organizations Teams") export class OrganizationsTeamsController { + constructor(private organizationsTeamsService: OrganizationsTeamsService) {} + @Get() + @UseGuards() + @Roles("ORG_ADMIN") async getAllTeams( @Param("orgId", ParseIntPipe) orgId: number, - @GetOrg() organization: Team, - @GetOrg("name") orgName: string - ): Promise> { - console.log(orgId, organization, orgName); + @Query() queryParams: SkipTakePagination + ): Promise> { + const { skip, take } = queryParams; + const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250); return { status: SUCCESS_STATUS, - data: [], + data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })), }; } - @UseGuards(IsTeamInOrg, RolesGuard) - @Roles("ORG_ADMIN") + @UseGuards(IsTeamInOrg) + @Roles("TEAM_ADMIN") @Get("/:teamId") - async getTeam( + async getTeam(@GetTeam() team: Team): Promise> { + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(IsTeamInOrg) + @Roles("ORG_ADMIN") + @Delete("/:teamId") + async deleteTeam( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("teamId", ParseIntPipe) teamId: number + ): Promise> { + const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(IsTeamInOrg) + @Roles("ORG_ADMIN") + @Patch("/:teamId") + async updateTeam( @Param("orgId", ParseIntPipe) orgId: number, @Param("teamId", ParseIntPipe) teamId: number, - @GetOrg() organization: Team, - @GetTeam() team: Team, - @GetOrg("name") orgName: string - ): Promise> { - console.log(teamId, orgId, organization, team, orgName); + @Body() body: CreateOrgTeamDto + ): Promise> { + const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Post() + @UseGuards() + @Roles("ORG_ADMIN") + async createTeam( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateOrgTeamDto + ): Promise> { + const team = await this.organizationsTeamsService.createOrgTeam(orgId, body); return { status: SUCCESS_STATUS, - data: team, + data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }), }; } } diff --git a/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts new file mode 100644 index 00000000000000..2167b95c500cff --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/create-organization-team.input.ts @@ -0,0 +1,75 @@ +import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class CreateOrgTeamDto { + @IsString() + @Length(1) + readonly name!: string; + + @IsOptional() + @IsString() + readonly slug?: string; + + @IsOptional() + @IsUrl() + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + readonly hideBranding?: boolean = false; + + @IsOptional() + @IsBoolean() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + readonly hideBookATeamMember?: boolean; + + @IsOptional() + @IsString() + readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + + @IsOptional() + @IsString() + readonly theme?: string; + + @IsOptional() + @IsString() + readonly brandColor?: string; + + @IsOptional() + @IsString() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + readonly weekStart?: string = "Sunday"; +} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index ba2f9a89b3fa0e..99002278e5e379 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -1,6 +1,8 @@ import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; @@ -8,8 +10,14 @@ import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, StripeModule], - providers: [OrganizationsRepository, OrganizationsService, MembershipsRepository], - exports: [OrganizationsService, OrganizationsRepository], + providers: [ + OrganizationsRepository, + OrganizationsTeamsRepository, + OrganizationsService, + OrganizationsTeamsService, + MembershipsRepository, + ], + exports: [OrganizationsService, OrganizationsRepository, OrganizationsTeamsRepository], controllers: [OrganizationsTeamsController], }) 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 807ac6d8a266b7..3058fd5a39db2f 100644 --- a/apps/api/v2/src/modules/organizations/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -70,17 +70,6 @@ export class OrganizationsRepository { }, }); } - - async findOrgTeam(organizationId: number, teamId: number) { - return this.dbRead.prisma.team.findUnique({ - where: { - id: teamId, - isOrganization: false, - parentId: organizationId, - }, - }); - } - async findOrgUser(organizationId: number, userId: number) { return this.dbRead.prisma.user.findUnique({ where: { diff --git a/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts new file mode 100644 index 00000000000000..d3cac09e70002d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/organization-team.output.ts @@ -0,0 +1,107 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class OrgTeamOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsInt() + @IsOptional() + @Expose() + readonly parentId?: number; + + @IsString() + @Length(1) + @Expose() + readonly name!: string; + + @IsOptional() + @IsString() + @Expose() + readonly slug?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @Expose() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @Expose() + readonly hideBranding?: boolean; + + @IsBoolean() + @Expose() + readonly isOrganization?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + readonly hideBookATeamMember?: boolean = false; + + @IsOptional() + @IsString() + @Expose() + readonly metadata?: string; + + @IsOptional() + @IsString() + @Expose() + readonly theme?: string; + + @IsOptional() + @IsString() + @Expose() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @Expose() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @Expose() + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @Expose() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @Expose() + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + @Expose() + readonly weekStart?: string = "Sunday"; +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts new file mode 100644 index 00000000000000..d51104ebfed893 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts @@ -0,0 +1,58 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly stripeService: StripeService + ) {} + + async findOrgTeam(organizationId: number, teamId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + isOrganization: false, + parentId: organizationId, + }, + }); + } + + async deleteOrgTeam(organizationId: number, teamId: number) { + return this.dbRead.prisma.team.delete({ + where: { + id: teamId, + isOrganization: false, + parentId: organizationId, + }, + }); + } + + async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) { + return this.dbRead.prisma.team.create({ + data: { ...data, parentId: organizationId }, + }); + } + + async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) { + return this.dbRead.prisma.team.update({ + data: { ...data }, + where: { id: teamId, parentId: organizationId, isOrganization: false }, + }); + } + + async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.team.findMany({ + where: { + parentId: organizationId, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts new file mode 100644 index 00000000000000..700c7be9158cc3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-teams.service.ts @@ -0,0 +1,28 @@ +import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsTeamsService { + constructor(private readonly organizationsTeamRepository: OrganizationsTeamsRepository) {} + + async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) { + const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take); + return teams; + } + + async deleteOrgTeam(organizationId: number, teamId: number) { + const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId); + return team; + } + + async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) { + const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data); + return team; + } + + async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) { + const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data); + return team; + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 66e81576f68963..7ce312b35a08d5 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -834,11 +834,68 @@ "tags": [ "Organizations Teams" ] + }, + "post": { + "operationId": "OrganizationsTeamsController_createTeam", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgTeamDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] } }, "/v2/organizations/{orgId}/teams/{teamId}": { "get": { "operationId": "OrganizationsTeamsController_getTeam", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] + }, + "delete": { + "operationId": "OrganizationsTeamsController_deleteTeam", "parameters": [ { "name": "orgId", @@ -872,6 +929,52 @@ "tags": [ "Organizations Teams" ] + }, + "patch": { + "operationId": "OrganizationsTeamsController_updateTeam", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgTeamDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Organizations Teams" + ] } }, "/v2/schedules": { @@ -3359,6 +3462,75 @@ "data" ] }, + "CreateOrgTeamDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string", + "minimum": 1 + }, + "slug": { + "type": "string", + "minimum": 1 + }, + "logoUrl": { + "type": "string" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London" + }, + "weekStart": { + "type": "string", + "default": "Sunday" + } + }, + "required": [ + "name" + ] + }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { diff --git a/packages/platform/types/api.ts b/packages/platform/types/api.ts index 76d576b6f64f39..0b4302264e3874 100644 --- a/packages/platform/types/api.ts +++ b/packages/platform/types/api.ts @@ -55,3 +55,18 @@ export class Pagination { @IsOptional() offset?: number | null; } + +export class SkipTakePagination { + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(1) + @Max(250) + @IsOptional() + take?: number; + + @Transform(({ value }: { value: string }) => value && parseInt(value)) + @IsNumber() + @Min(0) + @IsOptional() + skip?: number; +}