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
This commit is contained in:
Lauris Skraucis
2024-12-17 14:19:57 +00:00
committed by GitHub
parent b0094bad2d
commit a0f986f949
38 changed files with 2081 additions and 446 deletions
+2
View File
@@ -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"),
+2
View File
@@ -21,6 +21,8 @@ export type AppConfig = {
stripe: {
apiKey: string;
webhookSecret: string;
teamMonthlyPriceId: string;
isTeamBillingEnabled: boolean;
};
app: {
baseUrl: string;
@@ -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 {
+3 -1
View File
@@ -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 = <K extends keyof Environment>(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;
}
@@ -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;
}
}
@@ -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[] = [];
@@ -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);
}
@@ -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 {}
@@ -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 {}
@@ -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,
@@ -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;
}
}
@@ -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(", ")}`);
@@ -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],
@@ -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<AppConfig>,
@@ -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<User, "email" | "name" | "metadata">) {
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<User, "email" | "name" | "metadata">) {
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;
}
}
@@ -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: {
@@ -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();
});
});
@@ -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<CreateTeamOutput> {
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<GetTeamOutput> {
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<GetTeamsOutput> {
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<UpdateTeamOutput> {
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<OrgTeamOutputResponseDto> {
const team = await this.teamsRepository.delete(teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }),
};
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
}
@@ -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 {}
@@ -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 },
});
}
}
@@ -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<Team, "isPlatform" | "id" | "slug" | "name"> }) | 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 },
+474
View File
@@ -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": {
@@ -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) {
+1
View File
@@ -20,6 +20,7 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
// 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
+426
View File
@@ -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": {
@@ -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) },
@@ -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) },
+1
View File
@@ -8,3 +8,4 @@ export * from "./billing";
export * from "./schedules";
export * from "./event-types";
export * from "./organizations";
export * from "./teams";
@@ -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";
}
+1
View File
@@ -0,0 +1 @@
export * from "./outputs";
@@ -0,0 +1 @@
export * from "./team.output";
@@ -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";
}