From a0f986f949ba0b0d147d330b16513f8eb522bfa6 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Tue, 17 Dec 2024 15:19:57 +0100 Subject: [PATCH] feat: v2 teams CRUD (#18019) * feat: teams endpoints * refactor: organization teams reuse team types * refactor: move team specific functions out of organizations-teams.repository * supporting files * regenerate docs * feat: stripe service to generate team creation checkout * refactor: request payment when creating a team * fix: tests * fix env * driveby: event type name and email booking fields * finish merge main --- apps/api/v2/src/config/app.ts | 2 + apps/api/v2/src/config/type.ts | 2 + .../v2/src/ee/platform-endpoints-module.ts | 2 + apps/api/v2/src/env.ts | 4 +- .../v2/src/modules/atoms/atoms.repository.ts | 50 ++ .../services/event-types-atom.service.ts | 5 +- .../memberships/memberships.repository.ts | 10 + .../inputs/create-organization-team.input.ts | 119 +---- .../inputs/update-organization-team.input.ts | 120 +---- .../organizations/organizations.module.ts | 2 + .../organizations-teams.repository.ts | 75 --- .../services/event-types/input.service.ts | 10 +- .../v2/src/modules/stripe/stripe.module.ts | 3 +- .../v2/src/modules/stripe/stripe.service.ts | 107 +++- .../teams-memberships.repository.ts | 12 + .../controllers/teams.controller.e2e-spec.ts | 230 +++++++++ .../teams/controllers/teams.controller.ts | 105 ++++ .../teams/teams/inputs/create-team.input.ts | 118 +++++ .../teams/teams/inputs/update-team.input.ts | 119 +++++ .../teams/outputs/teams/create-team.output.ts | 34 ++ .../teams/outputs/teams/get-team.output.ts | 18 + .../teams/outputs/teams/get-teams.output.ts | 18 + .../teams/outputs/teams/update-team.output.ts | 18 + .../teams/teams/services/teams.service.ts | 71 +++ .../src/modules/teams/teams/teams.module.ts | 18 + .../modules/teams/teams/teams.repository.ts | 73 +++ .../v2/src/modules/users/users.repository.ts | 9 +- apps/api/v2/swagger/documentation.json | 474 ++++++++++++++++++ .../repository/team.repository.fixture.ts | 2 +- apps/api/v2/test/setEnvVars.ts | 1 + docs/api-reference/v2/openapi.json | 426 ++++++++++++++++ .../inputs/create-event-type.input.ts | 8 +- .../inputs/update-event-type.input.ts | 8 +- packages/platform/types/index.ts | 1 + .../teams/outputs/team.output.ts | 122 +---- packages/platform/types/teams/index.ts | 1 + .../platform/types/teams/outputs/index.ts | 1 + .../types/teams/outputs/team.output.ts | 129 +++++ 38 files changed, 2081 insertions(+), 446 deletions(-) create mode 100644 apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts create mode 100644 apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts create mode 100644 apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts create mode 100644 apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts create mode 100644 apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts create mode 100644 apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts create mode 100644 apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts create mode 100644 apps/api/v2/src/modules/teams/teams/services/teams.service.ts create mode 100644 apps/api/v2/src/modules/teams/teams/teams.module.ts create mode 100644 apps/api/v2/src/modules/teams/teams/teams.repository.ts create mode 100644 packages/platform/types/teams/index.ts create mode 100644 packages/platform/types/teams/outputs/index.ts create mode 100644 packages/platform/types/teams/outputs/team.output.ts diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts index 87db1b6645..79861bbdf3 100644 --- a/apps/api/v2/src/config/app.ts +++ b/apps/api/v2/src/config/app.ts @@ -30,6 +30,8 @@ const loadConfig = (): AppConfig => { stripe: { apiKey: getEnv("STRIPE_API_KEY"), webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"), + teamMonthlyPriceId: getEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "set-team-monthly-price-in-your-env"), + isTeamBillingEnabled: getEnv("IS_TEAM_BILLING_ENABLED", true), }, app: { baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"), diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts index ed08ad55a3..cd2fe252bf 100644 --- a/apps/api/v2/src/config/type.ts +++ b/apps/api/v2/src/config/type.ts @@ -21,6 +21,8 @@ export type AppConfig = { stripe: { apiKey: string; webhookSecret: string; + teamMonthlyPriceId: string; + isTeamBillingEnabled: boolean; }; app: { baseUrl: string; diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts index 3153cd63ac..a82c797165 100644 --- a/apps/api/v2/src/ee/platform-endpoints-module.ts +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module"; import { SlotsModule } from "@/modules/slots/slots.module"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; @@ -27,6 +28,7 @@ import { Module } from "@nestjs/common"; BookingsModule_2024_08_13, TeamsMembershipsModule, SlotsModule, + TeamsModule, ], }) export class PlatformEndpointsModule implements NestModule { diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts index c6f1eaded2..97d1ea64a3 100644 --- a/apps/api/v2/src/env.ts +++ b/apps/api/v2/src/env.ts @@ -30,12 +30,14 @@ export type Environment = { RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number; AXIOM_DATASET: string; AXIOM_TOKEN: string; + STRIPE_TEAM_MONTHLY_PRICE_ID: string; + IS_TEAM_BILLING_ENABLED: boolean; }; export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { const value = process.env[key] as Environment[K] | undefined; - if (!value) { + if (value === undefined) { if (fallback) { return fallback; } diff --git a/apps/api/v2/src/modules/atoms/atoms.repository.ts b/apps/api/v2/src/modules/atoms/atoms.repository.ts index c0d1ea534e..5a113d4e0f 100644 --- a/apps/api/v2/src/modules/atoms/atoms.repository.ts +++ b/apps/api/v2/src/modules/atoms/atoms.repository.ts @@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; +import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries"; import { paymentDataSelect } from "@calcom/platform-libraries"; @Injectable() @@ -14,4 +15,53 @@ export class AtomsRepository { select: paymentDataSelect, }); } + + async getUserTeams(userId: number) { + const userTeams = await this.dbRead.prisma.team.findMany({ + where: { + members: { + some: { + userId, + accepted: true, + }, + }, + }, + select: { + id: true, + credentials: { + select: credentialForCalendarServiceSelect, + }, + name: true, + logoUrl: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + parent: { + select: { + id: true, + credentials: { + select: credentialForCalendarServiceSelect, + }, + name: true, + logoUrl: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }, + }, + }); + + return userTeams; + } } 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 b568579cd4..a7bb974cdc 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 @@ -2,10 +2,10 @@ import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_ 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/repositories/organizations-teams.repository"; import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common"; @@ -46,7 +46,6 @@ export class EventTypesAtomService { private readonly membershipsRepository: MembershipsRepository, private readonly credentialsRepository: CredentialsRepository, private readonly atomsRepository: AtomsRepository, - private readonly organizationsTeamsRepository: OrganizationsTeamsRepository, private readonly usersService: UsersService, private readonly dbWrite: PrismaWriteService, private readonly dbRead: PrismaReadService, @@ -183,7 +182,7 @@ export class EventTypesAtomService { let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId); let userTeams: TeamQuery[] = []; if (teamId) { - const teamsQuery = await this.organizationsTeamsRepository.getUserTeamsById(userId); + const teamsQuery = await this.atomsRepository.getUserTeams(userId); // If a team is a part of an org then include those apps // Don't want to iterate over these parent teams const filteredTeams: TeamQuery[] = []; diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts index 9283cf6819..2c289e9243 100644 --- a/apps/api/v2/src/modules/memberships/memberships.repository.ts +++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts @@ -33,6 +33,16 @@ export class MembershipsRepository { return membership; } + async findUserMemberships(userId: number) { + const memberships = await this.dbRead.prisma.membership.findMany({ + where: { + userId, + }, + }); + + return memberships; + } + async findMembershipByOrgId(orgId: number, userId: number) { return this.findMembershipByTeamId(orgId, userId); } 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 index e86e4e9beb..e72e951138 100644 --- 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 @@ -1,118 +1,3 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; -export class CreateOrgTeamDto { - @IsString() - @Length(1) - @ApiProperty({ description: "Name of the team", example: "CalTeam", required: true }) - readonly name!: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) - readonly slug?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional({ - type: String, - example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", - description: `URL of the teams logo image`, - }) - readonly logoUrl?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly calVideoLogo?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly appLogo?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly appIconLogo?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly bio?: string; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional({ type: Boolean, default: false }) - readonly hideBranding?: boolean = false; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly isPrivate?: boolean; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly hideBookATeamMember?: boolean; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly theme?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly brandColor?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly darkBrandColor?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional({ - type: String, - example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", - description: `URL of the teams banner image which is shown on booker`, - required: false, - }) - readonly bannerUrl?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly timeFormat?: number; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ - type: String, - example: "America/New_York", - description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, - required: false, - default: "Europe/London", - }) - readonly timeZone?: string = "Europe/London"; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ - type: String, - example: "Monday", - default: "Sunday", - }) - readonly weekStart?: string = "Sunday"; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional({ type: Boolean, default: true }) - readonly autoAcceptCreator?: boolean = true; -} +export class CreateOrgTeamDto extends CreateTeamInput {} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts index ad220402c9..a69070ab97 100644 --- a/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts +++ b/apps/api/v2/src/modules/organizations/inputs/update-organization-team.input.ts @@ -1,119 +1,3 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; -export class UpdateOrgTeamDto { - @IsString() - @Length(1) - @ApiPropertyOptional({ description: "Name of the team", example: "CalTeam" }) - readonly name?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) - readonly slug?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional({ - type: String, - example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", - description: `URL of the teams logo image`, - }) - readonly logoUrl?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly calVideoLogo?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly appLogo?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional() - readonly appIconLogo?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly bio?: string; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly hideBranding?: boolean; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly isPrivate?: boolean; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly hideBookATeamMember?: boolean; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly theme?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly brandColor?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly darkBrandColor?: string; - - @IsOptional() - @IsUrl() - @ApiPropertyOptional({ - type: String, - example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", - description: `URL of the teams banner image which is shown on booker`, - }) - readonly bannerUrl?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly timeFormat?: number; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ - type: String, - example: "America/New_York", - description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, - }) - readonly timeZone?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional({ - type: String, - example: "Monday", - }) - readonly weekStart?: string; - - @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly bookingLimits?: string; - - @IsOptional() - @IsBoolean() - @ApiPropertyOptional() - readonly includeManagedEventsInLimits?: boolean; -} +export class UpdateOrgTeamDto extends UpdateTeamDto {} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index db6d9c695a..f82de9b164 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -39,6 +39,7 @@ import { OrganizationsService } from "@/modules/organizations/services/organizat import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; import { UsersModule } from "@/modules/users/users.module"; import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; @@ -53,6 +54,7 @@ import { Module } from "@nestjs/common"; RedisModule, EmailModule, EventTypesModule_2024_06_14, + TeamsModule, ], providers: [ OrganizationsRepository, 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 index 844ce31750..b92e056c0a 100644 --- a/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-teams.repository.ts @@ -4,8 +4,6 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; -import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries"; - @Injectable() export class OrganizationsTeamsRepository { constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} @@ -20,14 +18,6 @@ export class OrganizationsTeamsRepository { }); } - async findTeamById(teamId: number) { - return this.dbRead.prisma.team.findUnique({ - where: { - id: teamId, - }, - }); - } - async findOrgTeams(organizationId: number) { return this.dbRead.prisma.team.findMany({ where: { @@ -105,69 +95,4 @@ export class OrganizationsTeamsRepository { take, }); } - - async getTeamMembersIds(teamId: number) { - const team = await this.dbRead.prisma.team.findUnique({ - where: { - id: teamId, - }, - include: { - members: true, - }, - }); - if (!team) { - return []; - } - - return team.members.map((member) => member.userId); - } - - async getUserTeamsById(userId: number) { - const userTeams = await this.dbRead.prisma.team.findMany({ - where: { - members: { - some: { - userId, - accepted: true, - }, - }, - }, - select: { - id: true, - credentials: { - select: credentialForCalendarServiceSelect, - }, - name: true, - logoUrl: true, - members: { - where: { - userId, - }, - select: { - role: true, - }, - }, - parent: { - select: { - id: true, - credentials: { - select: credentialForCalendarServiceSelect, - }, - name: true, - logoUrl: true, - members: { - where: { - userId, - }, - select: { - role: true, - }, - }, - }, - }, - }, - }); - - return userTeams; - } } diff --git a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts index cf6c722a9c..f95c8ae402 100644 --- a/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts +++ b/apps/api/v2/src/modules/organizations/services/event-types/input.service.ts @@ -1,6 +1,6 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service"; import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; -import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersRepository } from "@/modules/users/users.repository"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; @@ -15,7 +15,7 @@ import { SchedulingType } from "@calcom/prisma/client"; export class InputOrganizationsEventTypesService { constructor( private readonly inputEventTypesService: InputEventTypesService_2024_06_14, - private readonly organizationsTeamsRepository: OrganizationsTeamsRepository, + private readonly teamsRepository: TeamsRepository, private readonly usersRepository: UsersRepository, private readonly orgEventTypesRepository: OrganizationsEventTypesRepository ) {} @@ -167,7 +167,7 @@ export class InputOrganizationsEventTypesService { eventType: { children: { userId: number | null }[] } | null ) { if (inputEventType.assignAllTeamMembers) { - return await this.organizationsTeamsRepository.getTeamMembersIds(teamId); + return await this.teamsRepository.getTeamMembersIds(teamId); } // note(Lauris): when API user updates managed event type users @@ -195,7 +195,7 @@ export class InputOrganizationsEventTypesService { } async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) { - const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId); + const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); const isFixed = schedulingType === "COLLECTIVE" ? true : false; return membersIds.map((id) => ({ @@ -227,7 +227,7 @@ export class InputOrganizationsEventTypesService { async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) { if (hosts && hosts.length) { - const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId); + const membersIds = await this.teamsRepository.getTeamMembersIds(teamId); const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId)); if (invalidHosts.length) { throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`); diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts index 4d6456eb2d..e504cde031 100644 --- a/apps/api/v2/src/modules/stripe/stripe.module.ts +++ b/apps/api/v2/src/modules/stripe/stripe.module.ts @@ -5,11 +5,12 @@ import { PrismaModule } from "@/modules/prisma/prisma.module"; import { StripeController } from "@/modules/stripe/controllers/stripe.controller"; import { StripeService } from "@/modules/stripe/stripe.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [ConfigModule, PrismaModule], + imports: [ConfigModule, PrismaModule, UsersModule], exports: [StripeService], providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository], controllers: [StripeController], diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts index ab8274a345..24127678d3 100644 --- a/apps/api/v2/src/modules/stripe/stripe.service.ts +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -6,9 +6,16 @@ import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturn import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance"; import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; -import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Injectable, + NotFoundException, + BadRequestException, + UnauthorizedException, + InternalServerErrorException, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import type { Prisma, Credential } from "@prisma/client"; +import type { Prisma, Credential, User } from "@prisma/client"; import Stripe from "stripe"; import { z } from "zod"; @@ -30,6 +37,9 @@ type IntegrationOAuthCallbackState = { export class StripeService { private stripe: Stripe; private redirectUri = `${this.config.get("api.url")}/stripe/save`; + private webAppUrl = this.config.get("app.baseUrl"); + private environment = this.config.get("env.type"); + private teamMonthlyPriceId = this.config.get("stripe.teamMonthlyPriceId"); constructor( configService: ConfigService, @@ -37,7 +47,8 @@ export class StripeService { private readonly appsRepository: AppsRepository, private readonly credentialRepository: CredentialsRepository, private readonly tokensRepository: TokensRepository, - private readonly membershipRepository: MembershipsRepository + private readonly membershipRepository: MembershipsRepository, + private readonly usersRepository: UsersRepository ) { this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", { apiVersion: "2020-08-27", @@ -176,4 +187,94 @@ export class StripeService { status: SUCCESS_STATUS, }; } + + async generateTeamCheckoutSession(pendingPaymentTeamId: number, ownerId: number) { + const stripe = this.getStripe(); + const customer = await this.getStripeCustomerIdFromUserId(ownerId); + + if (!customer) { + throw new BadRequestException("Failed to create a customer on Stripe."); + } + + const session = await stripe.checkout.sessions.create({ + customer, + mode: "subscription", + allow_promotion_codes: true, + success_url: `${this.webAppUrl}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${this.webAppUrl}/settings/my-account/profile`, + line_items: [ + { + /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ + price: this.teamMonthlyPriceId, + /**Initially it will be just the team owner */ + quantity: 1, + }, + ], + customer_update: { + address: "auto", + }, + // Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode + automatic_tax: { + enabled: this.environment === "production", + }, + metadata: { + pendingPaymentTeamId, + ownerId, + dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe + }, + }); + + if (!session.url) { + throw new InternalServerErrorException({ + message: "Failed generating a Stripe checkout session URL.", + }); + } + + return session; + } + + async getStripeCustomerIdFromUserId(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.email) return null; + const customerId = await this.getStripeCustomerId(user); + if (!customerId) { + return this.createStripeCustomerId(user); + } + + return customerId; + } + + async getStripeCustomerId(user: Pick) { + if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) { + return (user?.metadata as Prisma.JsonObject).stripeCustomerId as string; + } + return null; + } + + async createStripeCustomerId(user: Pick) { + let customerId: string; + + const stripe = this.getStripe(); + try { + const customersResponse = await stripe.customers.list({ + email: user.email, + limit: 1, + }); + + customerId = customersResponse.data[0].id; + } catch (error) { + const customer = await stripe.customers.create({ email: user.email }); + customerId = customer.id; + } + + await this.usersRepository.updateByEmail(user.email, { + metadata: { + ...(user.metadata as Prisma.JsonObject), + stripeCustomerId: customerId, + }, + }); + + return customerId; + } } diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts index d165c74d6c..e7004aa32a 100644 --- a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts @@ -35,6 +35,18 @@ export class TeamsMembershipsRepository { include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, }); } + + async findTeamMembershipsByNameAndUser(teamName: string, userId: number) { + return this.dbRead.prisma.membership.findFirst({ + where: { + team: { + name: teamName, + }, + userId, + }, + }); + } + async deleteTeamMembershipById(teamId: number, membershipId: number) { return this.dbWrite.prisma.membership.delete({ where: { diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts new file mode 100644 index 0000000000..db953454df --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts @@ -0,0 +1,230 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; +import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output"; +import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output"; +import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output"; +import { TeamsModule } from "@/modules/teams/teams/teams.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "next-auth"; +import Stripe from "stripe"; +import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomNumber } from "test/utils/randomNumber"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +describe("Teams endpoint", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + + const aliceEmail = `alice-${randomNumber()}@api.com`; + let alice: User; + let aliceApiKey: string; + + const bobEmail = `bob-${randomNumber()}@api.com`; + let bob: User; + let bobApiKey: string; + + let team1: TeamOutputDto; + let team2: TeamOutputDto; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, TeamsModule], + }).compile(); + + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(() => ({} as unknown as Stripe)); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + alice = await userRepositoryFixture.create({ + email: aliceEmail, + }); + + bob = await userRepositoryFixture.create({ + email: bobEmail, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(alice.id, null); + aliceApiKey = keyString; + + const { keyString: bobKeyString } = await apiKeysRepositoryFixture.createApiKey(bob.id, null); + bobApiKey = bobKeyString; + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("User has membership in created team", () => { + it("should create a team", async () => { + const body: CreateTeamInput = { + name: "Team dog", + }; + + return request(app.getHttpServer()) + .post("/v2/teams") + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(201) + .then(async (response) => { + const responseBody: CreateTeamOutput = response.body; + const responseData = responseBody.data as TeamOutputDto; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseData).toBeDefined(); + expect(responseData.id).toBeDefined(); + expect(responseData.name).toEqual(body.name); + team1 = responseData; + }); + }); + + it("should create a team", async () => { + const body: CreateTeamInput = { + name: "Team cats", + }; + + return request(app.getHttpServer()) + .post("/v2/teams") + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(201) + .then(async (response) => { + const responseBody: CreateTeamOutput = response.body; + const responseData = responseBody.data as TeamOutputDto; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseData).toBeDefined(); + expect(responseData.id).toBeDefined(); + expect(responseData.name).toEqual(body.name); + team2 = responseData; + }); + }); + + it("should get a team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team1.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team1.name); + }); + }); + + it("should get teams", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamsOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(2); + expect(responseBody.data[0].id).toBeDefined(); + expect(responseBody.data[0].name).toEqual(team1.name); + expect(responseBody.data[1].id).toBeDefined(); + expect(responseBody.data[1].name).toEqual(team2.name); + }); + }); + + it("should update a team", async () => { + const body: UpdateTeamDto = { + name: "Team dogs shepherds", + }; + + return request(app.getHttpServer()) + .patch(`/v2/teams/${team1.id}`) + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(body.name); + team1 = responseBody.data; + }); + }); + }); + + describe("User does not have membership in created team", () => { + it("should not be able to get a team", async () => { + return request(app.getHttpServer()) + .get(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${bobApiKey}` }) + .expect(403); + }); + }); + + describe("User does not have sufficient membership in created team", () => { + it("should not be able to delete a team", async () => { + await membershipRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: bob.id } }, + team: { connect: { id: team2.id } }, + accepted: true, + }); + return request(app.getHttpServer()) + .delete(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${bobApiKey}` }) + .expect(403); + }); + }); + + describe("Delete teams", () => { + it("should delete team", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team1.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team1.name); + }); + }); + + it("should delete team", async () => { + return request(app.getHttpServer()) + .delete(`/v2/teams/${team2.id}`) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(200) + .then(async (response) => { + const responseBody: GetTeamOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.name).toEqual(team2.name); + }); + }); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(aliceEmail); + await teamRepositoryFixture.delete(team1.id); + await teamRepositoryFixture.delete(team2.id); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts new file mode 100644 index 0000000000..9947214c85 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts @@ -0,0 +1,105 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input"; +import { OrgTeamOutputResponseDto } from "@/modules/organizations/outputs/organization-team.output"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output"; +import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output"; +import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output"; +import { UpdateTeamOutput } from "@/modules/teams/teams/outputs/teams/update-team.output"; +import { TeamsService } from "@/modules/teams/teams/services/teams.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Param, ParseIntPipe, Delete, Patch, Post, Body } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/teams", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard) +@DocsTags("Teams") +export class TeamsController { + constructor(private teamsService: TeamsService, private teamsRepository: TeamsRepository) {} + + @Post() + @ApiOperation({ summary: "Create a team" }) + async createTeam( + @Body() body: CreateTeamInput, + @GetUser() user: UserWithProfile + ): Promise { + const team = await this.teamsService.createTeam(body, user.id); + + if ("paymentLink" in team) { + return { + status: SUCCESS_STATUS, + data: { + pendingTeam: plainToClass(TeamOutputDto, team.pendingTeam, { strategy: "excludeAll" }), + paymentLink: team.paymentLink, + message: team.message, + }, + }; + } + + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Get("/:teamId") + @ApiOperation({ summary: "Get a team" }) + @UseGuards(RolesGuard) + @Roles("TEAM_MEMBER") + async getTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise { + const team = await this.teamsRepository.getById(teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get teams" }) + async getTeams(@GetUser("id") userId: number): Promise { + const teams = await this.teamsService.getUserTeams(userId); + return { + status: SUCCESS_STATUS, + data: teams.map((team) => plainToClass(TeamOutputDto, team, { strategy: "excludeAll" })), + }; + } + + @Patch("/:teamId") + @ApiOperation({ summary: "Update a team" }) + @UseGuards(RolesGuard) + @Roles("TEAM_OWNER") + async updateTeam( + @Param("teamId", ParseIntPipe) teamId: number, + @Body() body: UpdateOrgTeamDto + ): Promise { + const team = await this.teamsService.updateTeam(teamId, body); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } + + @UseGuards(RolesGuard) + @Delete("/:teamId") + @ApiOperation({ summary: "Delete a team" }) + @Roles("TEAM_OWNER") + async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise { + const team = await this.teamsRepository.delete(teamId); + return { + status: SUCCESS_STATUS, + data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts new file mode 100644 index 0000000000..c28a92609c --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts @@ -0,0 +1,118 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class CreateTeamInput { + @IsString() + @Length(1) + @ApiProperty({ description: "Name of the team", example: "CalTeam", required: true }) + readonly name!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) + readonly slug?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + description: `URL of the teams logo image`, + }) + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: false }) + readonly hideBranding?: boolean = false; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBookATeamMember?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly theme?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + description: `URL of the teams banner image which is shown on booker`, + required: false, + }) + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "America/New_York", + description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, + required: false, + default: "Europe/London", + }) + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "Monday", + default: "Sunday", + }) + readonly weekStart?: string = "Sunday"; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: Boolean, default: true }) + readonly autoAcceptCreator?: boolean = true; +} diff --git a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts new file mode 100644 index 0000000000..49685659e9 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts @@ -0,0 +1,119 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class UpdateTeamDto { + @IsString() + @Length(1) + @ApiPropertyOptional({ description: "Name of the team", example: "CalTeam" }) + readonly name?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" }) + readonly slug?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + description: `URL of the teams logo image`, + }) + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBranding?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly hideBookATeamMember?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly theme?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @ApiPropertyOptional({ + type: String, + example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + description: `URL of the teams banner image which is shown on booker`, + }) + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "America/New_York", + description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`, + }) + readonly timeZone?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ + type: String, + example: "Monday", + }) + readonly weekStart?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + readonly bookingLimits?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional() + readonly includeManagedEventsInLimits?: boolean; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts new file mode 100644 index 0000000000..b17ca06671 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/create-team.output.ts @@ -0,0 +1,34 @@ +import { ApiProperty, getSchemaPath } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +class Output { + @Expose() + @IsString() + message!: string; + + @Expose() + @IsUrl() + paymentLink!: string; + + @Expose() + @ValidateNested() + pendingTeam!: TeamOutputDto; +} + +export class CreateTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }], + description: "Either an Output object or a TeamOutputDto.", + }) + @Expose() + @ValidateNested() + data!: Output | TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts new file mode 100644 index 0000000000..066604be9a --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-team.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class GetTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts new file mode 100644 index 0000000000..66f55aeb71 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/get-teams.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class GetTeamsOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto[]; +} diff --git a/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts b/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts new file mode 100644 index 0000000000..2b0f4b493a --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/outputs/teams/update-team.output.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsString, ValidateNested } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; + +export class UpdateTeamOutput { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => TeamOutputDto) + data!: TeamOutputDto; +} diff --git a/apps/api/v2/src/modules/teams/teams/services/teams.service.ts b/apps/api/v2/src/modules/teams/teams/services/teams.service.ts new file mode 100644 index 0000000000..191296d96f --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/services/teams.service.ts @@ -0,0 +1,71 @@ +import { StripeService } from "@/modules/stripe/stripe.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input"; +import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Injectable() +export class TeamsService { + private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled"); + + constructor( + private readonly teamsRepository: TeamsRepository, + private readonly teamsMembershipsRepository: TeamsMembershipsRepository, + private readonly stripeService: StripeService, + private readonly configService: ConfigService + ) {} + + async createTeam(input: CreateTeamInput, ownerId: number) { + const { autoAcceptCreator, ...teamData } = input; + + const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser( + input.name, + ownerId + ); + if (existingTeam) { + throw new BadRequestException({ + message: `You already have created a team with name=${input.name}`, + }); + } + + if (!this.isTeamBillingEnabled) { + const team = await this.teamsRepository.create(teamData); + await this.teamsMembershipsRepository.createTeamMembership(team.id, { + userId: ownerId, + role: "OWNER", + accepted: !!autoAcceptCreator, + }); + return team; + } + + const pendingTeam = await this.teamsRepository.create({ ...teamData, pendingPayment: true }); + + const checkoutSession = await this.stripeService.generateTeamCheckoutSession(pendingTeam.id, ownerId); + + if (!checkoutSession.url) { + await this.teamsRepository.delete(pendingTeam.id); + throw new InternalServerErrorException({ + message: `Failed generating team Stripe checkout session URL which is why team creation was cancelled. Please contact support.`, + }); + } + + return { + message: + "Your team will be created once we receive your payment. Please complete the payment using the payment link.", + paymentLink: checkoutSession.url, + pendingTeam, + }; + } + + async getUserTeams(userId: number) { + const teams = await this.teamsRepository.getTeamsUserIsMemberOf(userId); + return teams; + } + + async updateTeam(teamId: number, data: UpdateTeamDto) { + const team = await this.teamsRepository.update(teamId, data); + return team; + } +} diff --git a/apps/api/v2/src/modules/teams/teams/teams.module.ts b/apps/api/v2/src/modules/teams/teams/teams.module.ts new file mode 100644 index 0000000000..440229cc6a --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/teams.module.ts @@ -0,0 +1,18 @@ +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service"; +import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository"; +import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller"; +import { TeamsService } from "@/modules/teams/teams/services/teams.service"; +import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, RedisModule, StripeModule], + providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository, TeamsMembershipsService], + controllers: [TeamsController], + exports: [TeamsRepository], +}) +export class TeamsModule {} diff --git a/apps/api/v2/src/modules/teams/teams/teams.repository.ts b/apps/api/v2/src/modules/teams/teams/teams.repository.ts new file mode 100644 index 0000000000..fd2a8094c3 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/teams.repository.ts @@ -0,0 +1,73 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { Prisma } from "@calcom/prisma/client"; + +@Injectable() +export class TeamsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async create(team: Prisma.TeamCreateInput) { + return this.dbWrite.prisma.team.create({ + data: team, + }); + } + + async getById(teamId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { id: teamId }, + }); + } + + async getByIds(teamIds: number[]) { + return this.dbRead.prisma.team.findMany({ + where: { + id: { + in: teamIds, + }, + }, + }); + } + + async getTeamMembersIds(teamId: number) { + const team = await this.dbRead.prisma.team.findUnique({ + where: { + id: teamId, + }, + include: { + members: true, + }, + }); + if (!team) { + return []; + } + + return team.members.map((member) => member.userId); + } + + async getTeamsUserIsMemberOf(userId: number) { + return this.dbRead.prisma.team.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + }); + } + + async update(teamId: number, team: Prisma.TeamUpdateInput) { + return this.dbWrite.prisma.team.update({ + where: { id: teamId }, + data: team, + }); + } + + async delete(teamId: number) { + return this.dbWrite.prisma.team.delete({ + where: { id: teamId }, + }); + } +} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 7cae2d7011..2a8cc0331b 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { Injectable, NotFoundException } from "@nestjs/common"; -import type { Profile, User, Team } from "@prisma/client"; +import type { Profile, User, Team, Prisma } from "@prisma/client"; export type UserWithProfile = User & { movedToProfile?: (Profile & { organization: Pick }) | null; @@ -184,6 +184,13 @@ export class UsersRepository { }); } + async updateByEmail(email: string, updateData: Prisma.UserUpdateInput) { + return this.dbWrite.prisma.user.update({ + where: { email }, + data: updateData, + }); + } + async updateUsername(userId: number, newUsername: string) { return this.dbWrite.prisma.user.update({ where: { id: userId }, diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index b04def8b28..2334ac5489 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -5403,6 +5403,157 @@ ] } }, + "/v2/teams": { + "post": { + "operationId": "TeamsController_createTeam", + "summary": "Create a team", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "get": { + "operationId": "TeamsController_getTeams", + "summary": "Get teams", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamsOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + } + }, + "/v2/teams/{teamId}": { + "get": { + "operationId": "TeamsController_getTeam", + "summary": "Get a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "patch": { + "operationId": "TeamsController_updateTeam", + "summary": "Update a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamOutput" + } + } + } + } + }, + "tags": [ + "Teams" + ] + }, + "delete": { + "operationId": "TeamsController_deleteTeam", + "summary": "Delete a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Teams" + ] + } + }, "/v2/teams/{teamId}/memberships": { "post": { "operationId": "TeamsMembershipsController_createTeamMembership", @@ -7263,6 +7414,56 @@ "type" ] }, + "NameFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "name", + "description": "only allowed value for type is `name`" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&name=bob`, the name field will be prefilled with this value and disabled." + } + }, + "required": [ + "type", + "label", + "placeholder" + ] + }, + "EmailFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "email", + "description": "only allowed value for type is `email`" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&email=bob@gmail.com`, the email field will be prefilled with this value and disabled." + } + }, + "required": [ + "type", + "label", + "placeholder" + ] + }, "BookerLayouts_2024_06_14": { "type": "object", "properties": { @@ -7392,6 +7593,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -9117,6 +9324,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -10688,6 +10901,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -11507,6 +11726,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -12654,6 +12879,255 @@ "data" ] }, + "CreateTeamInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the team", + "example": "CalTeam" + }, + "slug": { + "type": "string", + "description": "Team slug", + "example": "caltel" + }, + "logoUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + "description": "URL of the teams logo image" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + "description": "URL of the teams banner image which is shown on booker" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London", + "example": "America/New_York", + "description": "Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed." + }, + "weekStart": { + "type": "string", + "default": "Sunday", + "example": "Monday" + }, + "autoAcceptCreator": { + "type": "boolean", + "default": true + } + }, + "required": [ + "name" + ] + }, + "CreateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/Output" + }, + { + "$ref": "#/components/schemas/TeamOutputDto" + } + ], + "description": "Either an Output object or a TeamOutputDto." + } + }, + "required": [ + "status", + "data" + ] + }, + "TeamOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "parentId": { + "type": "number" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isOrganization": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean", + "default": false + }, + "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": [ + "id", + "name", + "isOrganization" + ] + }, + "GetTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "GetTeamsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, "AuthUrlData": { "type": "object", "properties": { diff --git a/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts index 14af29cdd5..0e917e3e12 100644 --- a/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts @@ -21,7 +21,7 @@ export class TeamRepositoryFixture { } async delete(teamId: Team["id"]) { - return this.prismaWriteClient.team.delete({ where: { id: teamId } }); + return this.prismaWriteClient.team.deleteMany({ where: { id: teamId } }); } async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) { diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 2aa6f8672c..76aa4dea23 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -20,6 +20,7 @@ const env: Partial> = { // note(Lauris): setting high limit so that e2e tests themselves are not rate limited RATE_LIMIT_DEFAULT_LIMIT: 10000, RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000, + IS_TEAM_BILLING_ENABLED: false, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 122165065b..34f3803c86 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -5130,6 +5130,147 @@ "tags": ["Stripe"] } }, + "/v2/teams": { + "post": { + "operationId": "TeamsController_createTeam", + "summary": "Create a team", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamOutput" + } + } + } + } + }, + "tags": ["Teams"] + }, + "get": { + "operationId": "TeamsController_getTeams", + "summary": "Get teams", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamsOutput" + } + } + } + } + }, + "tags": ["Teams"] + } + }, + "/v2/teams/{teamId}": { + "get": { + "operationId": "TeamsController_getTeam", + "summary": "Get a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTeamOutput" + } + } + } + } + }, + "tags": ["Teams"] + }, + "patch": { + "operationId": "TeamsController_updateTeam", + "summary": "Update a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOrgTeamDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTeamOutput" + } + } + } + } + }, + "tags": ["Teams"] + }, + "delete": { + "operationId": "TeamsController_deleteTeam", + "summary": "Delete a team", + "parameters": [ + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgTeamOutputResponseDto" + } + } + } + } + }, + "tags": ["Teams"] + } + }, "/v2/teams/{teamId}/memberships": { "post": { "operationId": "TeamsMembershipsController_createTeamMembership", @@ -6728,6 +6869,48 @@ }, "required": ["type"] }, + "NameFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "name", + "description": "only allowed value for type is `name`" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&name=bob`, the name field will be prefilled with this value and disabled." + } + }, + "required": ["type", "label", "placeholder"] + }, + "EmailFieldInput_2024_06_14": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "email", + "description": "only allowed value for type is `email`" + }, + "label": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "disableOnPrefill": { + "type": "boolean", + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if URL contains query parameter `&email=bob@gmail.com`, the email field will be prefilled with this value and disabled." + } + }, + "required": ["type", "label", "placeholder"] + }, "BookerLayouts_2024_06_14": { "type": "object", "properties": { @@ -6836,6 +7019,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -8393,6 +8582,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -9730,6 +9925,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -10483,6 +10684,12 @@ "description": "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", "items": { "oneOf": [ + { + "$ref": "#/components/schemas/NameFieldInput_2024_06_14" + }, + { + "$ref": "#/components/schemas/EmailFieldInput_2024_06_14" + }, { "$ref": "#/components/schemas/PhoneFieldInput_2024_06_14" }, @@ -11421,6 +11628,225 @@ }, "required": ["status", "data"] }, + "CreateTeamInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name of the team", + "example": "CalTeam" + }, + "slug": { + "type": "string", + "description": "Team slug", + "example": "caltel" + }, + "logoUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", + "description": "URL of the teams logo image" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean", + "default": false + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean" + }, + "metadata": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "brandColor": { + "type": "string" + }, + "darkBrandColor": { + "type": "string" + }, + "bannerUrl": { + "type": "string", + "example": "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", + "description": "URL of the teams banner image which is shown on booker" + }, + "timeFormat": { + "type": "number" + }, + "timeZone": { + "type": "string", + "default": "Europe/London", + "example": "America/New_York", + "description": "Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed." + }, + "weekStart": { + "type": "string", + "default": "Sunday", + "example": "Monday" + }, + "autoAcceptCreator": { + "type": "boolean", + "default": true + } + }, + "required": ["name"] + }, + "CreateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/Output" + }, + { + "$ref": "#/components/schemas/TeamOutputDto" + } + ], + "description": "Either an Output object or a TeamOutputDto." + } + }, + "required": ["status", "data"] + }, + "TeamOutputDto": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "parentId": { + "type": "number" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "calVideoLogo": { + "type": "string" + }, + "appLogo": { + "type": "string" + }, + "appIconLogo": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "hideBranding": { + "type": "boolean" + }, + "isOrganization": { + "type": "boolean" + }, + "isPrivate": { + "type": "boolean" + }, + "hideBookATeamMember": { + "type": "boolean", + "default": false + }, + "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": ["id", "name", "isOrganization"] + }, + "GetTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": ["status", "data"] + }, + "GetTeamsOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamOutputDto" + } + } + }, + "required": ["status", "data"] + }, + "UpdateTeamOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/TeamOutputDto" + } + }, + "required": ["status", "data"] + }, "AuthUrlData": { "type": "object", "properties": { diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index fd4d580cd1..cf00f7952e 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -26,8 +26,10 @@ import { AddressFieldInput_2024_06_14, BooleanFieldInput_2024_06_14, CheckboxGroupFieldInput_2024_06_14, + EmailFieldInput_2024_06_14, MultiEmailFieldInput_2024_06_14, MultiSelectFieldInput_2024_06_14, + NameFieldInput_2024_06_14, NumberFieldInput_2024_06_14, PhoneFieldInput_2024_06_14, RadioGroupFieldInput_2024_06_14, @@ -103,7 +105,9 @@ export const CREATE_EVENT_SLUG_EXAMPLE = "learn-the-secrets-of-masterchief"; Seats_2024_06_14, InputAttendeeAddressLocation_2024_06_14, InputAttendeePhoneLocation_2024_06_14, - InputAttendeeDefinedLocation_2024_06_14 + InputAttendeeDefinedLocation_2024_06_14, + NameFieldInput_2024_06_14, + EmailFieldInput_2024_06_14 ) export class CreateEventTypeInput_2024_06_14 { @IsInt() @@ -162,6 +166,8 @@ export class CreateEventTypeInput_2024_06_14 { description: "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", oneOf: [ + { $ref: getSchemaPath(NameFieldInput_2024_06_14) }, + { $ref: getSchemaPath(EmailFieldInput_2024_06_14) }, { $ref: getSchemaPath(PhoneFieldInput_2024_06_14) }, { $ref: getSchemaPath(AddressFieldInput_2024_06_14) }, { $ref: getSchemaPath(TextFieldInput_2024_06_14) }, diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts index 8d5a4a8c36..389fdbe673 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts @@ -24,8 +24,10 @@ import { AddressFieldInput_2024_06_14, BooleanFieldInput_2024_06_14, CheckboxGroupFieldInput_2024_06_14, + EmailFieldInput_2024_06_14, MultiEmailFieldInput_2024_06_14, MultiSelectFieldInput_2024_06_14, + NameFieldInput_2024_06_14, NumberFieldInput_2024_06_14, PhoneFieldInput_2024_06_14, RadioGroupFieldInput_2024_06_14, @@ -101,7 +103,9 @@ import { Seats_2024_06_14 } from "./seats.input"; Seats_2024_06_14, InputAttendeeAddressLocation_2024_06_14, InputAttendeePhoneLocation_2024_06_14, - InputAttendeeDefinedLocation_2024_06_14 + InputAttendeeDefinedLocation_2024_06_14, + NameFieldInput_2024_06_14, + EmailFieldInput_2024_06_14 ) export class UpdateEventTypeInput_2024_06_14 { @IsOptional() @@ -163,6 +167,8 @@ export class UpdateEventTypeInput_2024_06_14 { description: "Custom fields that can be added to the booking form when the event is booked by someone. By default booking form has name and email field.", oneOf: [ + { $ref: getSchemaPath(NameFieldInput_2024_06_14) }, + { $ref: getSchemaPath(EmailFieldInput_2024_06_14) }, { $ref: getSchemaPath(PhoneFieldInput_2024_06_14) }, { $ref: getSchemaPath(AddressFieldInput_2024_06_14) }, { $ref: getSchemaPath(TextFieldInput_2024_06_14) }, diff --git a/packages/platform/types/index.ts b/packages/platform/types/index.ts index 2628472372..4ba50e7540 100644 --- a/packages/platform/types/index.ts +++ b/packages/platform/types/index.ts @@ -8,3 +8,4 @@ export * from "./billing"; export * from "./schedules"; export * from "./event-types"; export * from "./organizations"; +export * from "./teams"; diff --git a/packages/platform/types/organizations/teams/outputs/team.output.ts b/packages/platform/types/organizations/teams/outputs/team.output.ts index eccfdd855d..9a7792dc7a 100644 --- a/packages/platform/types/organizations/teams/outputs/team.output.ts +++ b/packages/platform/types/organizations/teams/outputs/team.output.ts @@ -1,129 +1,13 @@ import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Expose } from "class-transformer"; -import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { IsInt, IsOptional } from "class-validator"; -export class OrgTeamOutputDto { - @IsInt() - @Expose() - @DocsProperty() - readonly id!: number; +import { TeamOutputDto } from "../../../teams/outputs"; +export class OrgTeamOutputDto extends TeamOutputDto { @IsInt() @IsOptional() @Expose() @ApiPropertyOptional() readonly parentId?: number; - - @IsString() - @Length(1) - @Expose() - @DocsProperty() - readonly name!: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly slug?: string; - - @IsOptional() - @IsUrl() - @Expose() - @ApiPropertyOptional() - readonly logoUrl?: string; - - @IsOptional() - @IsUrl() - @Expose() - @ApiPropertyOptional() - readonly calVideoLogo?: string; - - @IsOptional() - @IsUrl() - @Expose() - @ApiPropertyOptional() - readonly appLogo?: string; - - @IsOptional() - @IsUrl() - @Expose() - @ApiPropertyOptional() - readonly appIconLogo?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly bio?: string; - - @IsOptional() - @IsBoolean() - @Expose() - @ApiPropertyOptional() - readonly hideBranding?: boolean; - - @IsBoolean() - @Expose() - @DocsProperty() - readonly isOrganization!: boolean; - - @IsOptional() - @IsBoolean() - @Expose() - @ApiPropertyOptional() - readonly isPrivate?: boolean; - - @IsOptional() - @IsBoolean() - @Expose() - @ApiPropertyOptional() - readonly hideBookATeamMember?: boolean = false; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly metadata?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly theme?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly brandColor?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly darkBrandColor?: string; - - @IsOptional() - @IsUrl() - @Expose() - @ApiPropertyOptional() - readonly bannerUrl?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly timeFormat?: number; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional({ type: String, default: "Europe/London" }) - readonly timeZone?: string = "Europe/London"; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional({ type: String, default: "Sunday" }) - readonly weekStart?: string = "Sunday"; } diff --git a/packages/platform/types/teams/index.ts b/packages/platform/types/teams/index.ts new file mode 100644 index 0000000000..fb53cfa187 --- /dev/null +++ b/packages/platform/types/teams/index.ts @@ -0,0 +1 @@ +export * from "./outputs"; diff --git a/packages/platform/types/teams/outputs/index.ts b/packages/platform/types/teams/outputs/index.ts new file mode 100644 index 0000000000..919d6058c9 --- /dev/null +++ b/packages/platform/types/teams/outputs/index.ts @@ -0,0 +1 @@ +export * from "./team.output"; diff --git a/packages/platform/types/teams/outputs/team.output.ts b/packages/platform/types/teams/outputs/team.output.ts new file mode 100644 index 0000000000..1c59c3099e --- /dev/null +++ b/packages/platform/types/teams/outputs/team.output.ts @@ -0,0 +1,129 @@ +import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +export class TeamOutputDto { + @IsInt() + @Expose() + @DocsProperty() + readonly id!: number; + + @IsInt() + @IsOptional() + @Expose() + @ApiPropertyOptional() + readonly parentId?: number; + + @IsString() + @Length(1) + @Expose() + @DocsProperty() + readonly name!: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly slug?: string; + + @IsOptional() + @IsUrl() + @Expose() + @ApiPropertyOptional() + readonly logoUrl?: string; + + @IsOptional() + @IsUrl() + @Expose() + @ApiPropertyOptional() + readonly calVideoLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + @ApiPropertyOptional() + readonly appLogo?: string; + + @IsOptional() + @IsUrl() + @Expose() + @ApiPropertyOptional() + readonly appIconLogo?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly bio?: string; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly hideBranding?: boolean; + + @IsBoolean() + @Expose() + @DocsProperty() + readonly isOrganization!: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly isPrivate?: boolean; + + @IsOptional() + @IsBoolean() + @Expose() + @ApiPropertyOptional() + readonly hideBookATeamMember?: boolean = false; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly metadata?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly theme?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly brandColor?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly darkBrandColor?: string; + + @IsOptional() + @IsUrl() + @Expose() + @ApiPropertyOptional() + readonly bannerUrl?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly timeFormat?: number; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional({ type: String, default: "Europe/London" }) + readonly timeZone?: string = "Europe/London"; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional({ type: String, default: "Sunday" }) + readonly weekStart?: string = "Sunday"; +}