feat: v2 managed organizations (#19341)

* refactor: allow non unique PlatformBilling customerId

* feat: add PlatformBilling managed organizations fields

* feat: ManagedOrganization table

* refactor: bill overdue based on teamId

* refactor: restructure organizations module

* wip: organizations endpoints

* Revert "wip: organizations endpoints"

This reverts commit 0e9e66fc74f31da436f12930c1b6597c650ed621.

* refactor: unique index for managed organizations

* wip: organizations endpoints

* wip: create managed organization

* remove unecessary membership check because we have guard

* feat: create managed org

* feat: get managed org and orgs

* feat: update and delete managed orgs

* wip: api key logic

* feat: allow variable api key length

* feat: refresh managed org api key

* feat: create managed org OAuth clients using api key

* finish merge main

* chore: bump platform libraries

* tests: fix and add more tests to api-auth.strategy.e2e

* refactor: dont request managed org slug and handle metadata as object

* revert: billing service and repository update overdue based on sub and customer ids

* refactor: v2 OAuth client permissions (#19501)

* refactor: v2 permissions as string array

* refactor: frontend work with permissions as string

* tests: test '*' permissions

* fix:managed org creator have profile & test can fetch oauth client

* fix: tests

* fix: OAuthClientCard on frontend

* fix: tests
This commit is contained in:
Lauris Skraucis
2025-02-25 18:10:46 -07:00
committed by GitHub
parent e4ab8d87c4
commit a44bcafcf2
192 changed files with 9103 additions and 619 deletions
+2 -1
View File
@@ -30,7 +30,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.99",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.110",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -91,6 +91,7 @@
"@types/supertest": "^2.0.12",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"node-mocks-http": "^1.16.2",
"prettier": "^2.8.6",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
@@ -1,5 +1,5 @@
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -11,7 +11,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeysRepository],
controllers: [BookingsController_2024_04_15],
})
export class BookingsModule_2024_04_15 {}
@@ -6,7 +6,7 @@ import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/g
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -98,7 +98,7 @@ export class BookingsController_2024_04_15 {
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository
private readonly apiKeyRepository: ApiKeysRepository
) {}
@Get("/")
@@ -4,7 +4,7 @@ import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bo
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
@@ -29,7 +29,7 @@ import { Module } from "@nestjs/common";
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
ApiKeyRepository,
ApiKeysRepository,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -5,7 +5,7 @@ import {
} from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -74,7 +74,7 @@ export class InputBookingsService_2024_08_13 {
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly bookingSeatRepository: BookingSeatRepository
) {}
+1 -6
View File
@@ -3,12 +3,7 @@ import { ZodSchema } from "zod";
const logger = new Logger("safeParse");
export function safeParse<T>(
schema: ZodSchema<T>,
value: unknown,
defaultValue: T,
logError = true
): T {
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T, logError = true): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
@@ -1,10 +0,0 @@
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [ApiKeyRepository],
exports: [ApiKeyRepository],
})
export class ApiKeyModule {}
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ApiKeyRepository {
export class ApiKeysRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getApiKeyFromHash(hashedKey: string) {
@@ -13,4 +13,20 @@ export class ApiKeyRepository {
},
});
}
async getTeamApiKeys(teamId: number) {
return this.dbRead.prisma.apiKey.findMany({
where: {
teamId,
},
});
}
async deleteById(id: string) {
return this.dbWrite.prisma.apiKey.delete({
where: {
id,
},
});
}
}
@@ -0,0 +1,13 @@
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { ApiKeysController } from "@/modules/api-keys/controllers/api-keys.controller";
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [ApiKeysRepository, ApiKeysService],
controllers: [ApiKeysController],
exports: [ApiKeysRepository, ApiKeysService],
})
export class ApiKeysModule {}
@@ -0,0 +1,48 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { ApiAuthGuardRequest } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
import { ApiTags, ApiHeader, ApiOperation } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/api-keys",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard)
@ApiTags("Api Keys")
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@ApiOperation({
summary: "Refresh API Key",
description: `Generate a new API key and delete the current one. Provide API key to refresh as a Bearer token in the Authorization header (e.g. "Authorization: Bearer <apiKey>").`,
})
@ApiHeader({
name: "Authorization",
description: "Bearer <apiKey>",
required: true,
schema: { type: "string" },
})
@Post("/refresh")
@HttpCode(HttpStatus.OK)
async refresh(
@Body() body: RefreshApiKeyInput,
@GetUser("id") userId: number,
@Req() request: ApiAuthGuardRequest
): Promise<RefreshApiKeyOutput> {
const currentApiKey = await this.apiKeysService.getRequestApiKey(request);
const newApiKey = await this.apiKeysService.refreshApiKey(userId, currentApiKey, body);
return {
status: SUCCESS_STATUS,
data: {
apiKey: newApiKey,
},
};
}
}
@@ -0,0 +1,20 @@
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsInt, IsOptional, IsString } from "class-validator";
export class CreateApiKeyInput extends RefreshApiKeyInput {
@IsOptional()
@IsInt()
@ApiPropertyOptional({
description: "For which team or organization is the api key.",
})
readonly teamId?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: "Store additional note about this api key.",
example: "This is an api key for development purposes.",
})
readonly note?: string;
}
@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsInt, IsOptional, Min } from "class-validator";
export class RefreshApiKeyInput {
@IsOptional()
@IsInt()
@Min(1)
@ApiPropertyOptional({
description: "For how many days is managed organization api key valid. Defaults to 30 days.",
example: 60,
default: 30,
minimum: 1,
})
readonly apiKeyDaysValid?: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ description: "If true, organization api key never expires.", example: true })
readonly apiKeyNeverExpires?: boolean;
}
@@ -0,0 +1,10 @@
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString } from "class-validator";
export class ApiKeyOutput {
@IsString()
@Expose()
@DocsProperty()
readonly apiKey!: string;
}
@@ -0,0 +1,18 @@
import { ApiKeyOutput } from "@/modules/api-keys/outputs/api-key.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class RefreshApiKeyOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({ type: ApiKeyOutput })
@Expose()
@ValidateNested()
@Type(() => ApiKeyOutput)
data!: ApiKeyOutput;
}
@@ -0,0 +1,80 @@
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
import { ApiAuthGuardRequest } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DateTime } from "luxon";
import { createApiKeyHandler } from "@calcom/platform-libraries";
@Injectable()
export class ApiKeysService {
constructor(
private readonly apiKeysRepository: ApiKeysRepository,
private readonly config: ConfigService
) {}
async getRequestApiKey(request: ApiAuthGuardRequest) {
if (request.authMethod !== AuthMethods.API_KEY) {
throw new UnauthorizedException(
"ApiKeysService - This endpoint can only be accessed using an API key by providing 'Authorization: Bearer <apiKey>' header"
);
}
const apiKey = request.get("Authorization")?.replace("Bearer ", "");
if (!apiKey) {
throw new UnauthorizedException("ApiKeysService - No API key provided");
}
return apiKey;
}
async createApiKey(authUserId: number, createApiKeyInput: CreateApiKeyInput) {
if (createApiKeyInput.apiKeyDaysValid && createApiKeyInput.apiKeyNeverExpires) {
throw new BadRequestException(
"ApiKeysService -Cannot set both apiKeyDaysValid and apiKeyNeverExpires. It has to be either or none of them."
);
}
const defaultApiKeyDaysValid = 30;
const apiKeyExpiresAfterDays = createApiKeyInput.apiKeyDaysValid
? createApiKeyInput.apiKeyDaysValid
: defaultApiKeyDaysValid;
const apiKeyExpiresAt = DateTime.utc().plus({ days: apiKeyExpiresAfterDays }).toJSDate();
const apiKey = await createApiKeyHandler({
ctx: {
user: {
id: authUserId,
},
},
input: {
note: createApiKeyInput.note,
neverExpires: !!createApiKeyInput.apiKeyNeverExpires,
expiresAt: apiKeyExpiresAt,
teamId: createApiKeyInput.teamId,
},
});
return apiKey;
}
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
if (!apiKeyInDb) {
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
}
const newApiKey = await this.createApiKey(authUserId, {
...refreshApiKeyInput,
note: apiKeyInDb.note || undefined,
teamId: apiKeyInDb.teamId || undefined,
});
await this.apiKeysRepository.deleteById(apiKeyInDb.id);
return newApiKey;
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ApiKeyModule } from "@/modules/api-key/api-key.module";
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
@@ -17,7 +17,7 @@ import { PassportModule } from "@nestjs/passport";
imports: [
PassportModule,
RedisModule,
ApiKeyModule,
ApiKeysModule,
UsersModule,
MembershipsModule,
TokensModule,
@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext, UnauthorizedException } from "@nestjs/common";
export const GetOrgId = createParamDecorator((_: unknown, ctx: ExecutionContext): number => {
const request = ctx.switchToHttp().getRequest();
const organizationId = request.organizationId;
if (!organizationId) {
throw new UnauthorizedException("GetOrgId decorator: Organization ID not found");
}
return organizationId;
});
@@ -1,5 +1,5 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
@@ -1,7 +1,7 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
@@ -1,4 +1,4 @@
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import {
Injectable,
CanActivate,
@@ -1,6 +1,6 @@
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
@@ -20,10 +20,10 @@ export class OrganizationRolesGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const organizationId = this.getOrganizationId(context);
if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user.");
throw new ForbiddenException("OrganizationRolesGuard - No organization associated with the user.");
}
await this.isPlatform(organizationId);
@@ -40,13 +40,25 @@ export class OrganizationRolesGuard implements CanActivate {
async isPlatform(organizationId: number) {
const isPlatform = await this.organizationsService.isPlatform(organizationId);
if (!isPlatform) {
throw new ForbiddenException("Organization is not a platform (SHP).");
throw new ForbiddenException("OrganizationRolesGuard - Organization is not a platform (SHP).");
}
}
getOrganizationId(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user;
const authMethodOrganizationId = request.organizationId;
if (authMethodOrganizationId) return authMethodOrganizationId;
const userOrganizationId = user ? this.usersService.getUserMainOrgId(user) : null;
return userOrganizationId;
}
isMembershipAccepted(accepted: boolean) {
if (!accepted) {
throw new ForbiddenException(`User has not accepted membership in the organization.`);
throw new ForbiddenException(
`OrganizationRolesGuard - User has not accepted membership in the organization.`
);
}
}
@@ -57,7 +69,9 @@ export class OrganizationRolesGuard implements CanActivate {
const hasRequiredRole = allowedRoles.includes(membershipRole);
if (!hasRequiredRole) {
throw new ForbiddenException(`User must have one of the roles: ${allowedRoles.join(", ")}.`);
throw new ForbiddenException(
`OrganizationRolesGuard - User must have one of the roles: ${allowedRoles.join(", ")}.`
);
}
}
}
@@ -1,4 +1,4 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@@ -0,0 +1,43 @@
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsManagedOrgInManagerOrg implements CanActivate {
constructor(private managedOrganizationsRepository: ManagedOrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { managedOrganization: Team }>();
const managedOrgId: string = request.params.managedOrganizationId;
const managerOrgId: string = request.params.orgId;
if (!managerOrgId) {
throw new ForbiddenException("No manager org id found in request params.");
}
if (!managedOrgId) {
throw new ForbiddenException("No managed org id found in request params.");
}
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
Number(managerOrgId),
Number(managedOrgId)
);
if (!managedOrganization) {
throw new NotFoundException(
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.`
);
}
return true;
}
}
@@ -1,4 +1,4 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@@ -1,5 +1,5 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhooks/organizations-webhooks.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@@ -1,4 +1,4 @@
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import {
Injectable,
CanActivate,
@@ -1,4 +1,4 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@@ -1,4 +1,4 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@@ -1,5 +1,6 @@
import appConfig from "@/config/app";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { JwtService } from "@/modules/jwt/jwt.service";
@@ -9,6 +10,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { ExecutionContext, HttpException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -16,6 +18,7 @@ import { ConfigModule } from "@nestjs/config";
import { JwtService as NestJwtService } from "@nestjs/jwt";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { createRequest } from "node-mocks-http";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -27,6 +30,7 @@ import { randomString } from "test/utils/randomString";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiAuthGuardRequest } from "./api-auth.strategy";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -35,7 +39,9 @@ describe("ApiAuthStrategy", () => {
let tokensRepositoryFixture: TokensRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let organization: Team;
let organizationTwo: Team;
let oAuthClient: PlatformOAuthClient;
let oAuthClientTwo: PlatformOAuthClient;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
@@ -65,7 +71,8 @@ describe("ApiAuthStrategy", () => {
ConfigService,
OAuthFlowService,
UsersRepository,
ApiKeyRepository,
UsersService,
ApiKeysRepository,
DeploymentsService,
OAuthClientRepository,
PrismaReadService,
@@ -84,7 +91,11 @@ describe("ApiAuthStrategy", () => {
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-${randomString()}` });
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
organizationTwo = await teamRepositoryFixture.create({
name: `api-auth-organization-2-${randomString()}`,
});
validApiKeyUser = await userRepositoryFixture.create({
email: validApiKeyEmail,
});
@@ -103,6 +114,13 @@ describe("ApiAuthStrategy", () => {
organization: { connect: { id: organization.id } },
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: validOAuthEmail,
user: { connect: { id: validOAuthUser.id } },
organization: { connect: { id: organizationTwo.id } },
});
const data = {
logo: "logo-url",
name: "name",
@@ -110,6 +128,7 @@ describe("ApiAuthStrategy", () => {
permissions: 32,
};
oAuthClient = await oAuthClientRepositoryFixture.create(organization.id, data, "secret");
oAuthClientTwo = await oAuthClientRepositoryFixture.create(organizationTwo.id, data, "secret");
});
describe("authenticate with strategy", () => {
@@ -119,25 +138,68 @@ describe("ApiAuthStrategy", () => {
oAuthClient.id
);
const user = await strategy.accessTokenStrategy(accessToken);
const mockRequest = createRequest() as ApiAuthGuardRequest;
mockRequest.authMethod = AuthMethods.ACCESS_TOKEN;
mockRequest.organizationId = null;
const user = await strategy.accessTokenStrategy(accessToken, mockRequest);
expect(user).toBeDefined();
expect(user?.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
it("should return user associated with valid api key and correctly set organizationId for org1", async () => {
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
validApiKeyUser.id,
now,
organization.id
);
const user = await strategy.apiKeyStrategy(keyString);
const mockRequest = createRequest() as ApiAuthGuardRequest;
mockRequest.authMethod = AuthMethods.API_KEY;
mockRequest.organizationId = null;
const user = await strategy.apiKeyStrategy(keyString, mockRequest);
expect(user).toBeDefined();
expect(user?.id).toEqual(validApiKeyUser.id);
expect(mockRequest.organizationId).toEqual(organization.id);
});
it("should return user associated with valid api key and correctly set organizationId for org2", async () => {
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
validApiKeyUser.id,
now,
organizationTwo.id
);
const mockRequest = createRequest() as ApiAuthGuardRequest;
mockRequest.authMethod = AuthMethods.API_KEY;
mockRequest.organizationId = null;
const user = await strategy.apiKeyStrategy(keyString, mockRequest);
expect(user).toBeDefined();
expect(user?.id).toEqual(validApiKeyUser.id);
expect(mockRequest.organizationId).toEqual(organizationTwo.id);
});
it("should return user associated with valid OAuth client", async () => {
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
const mockRequest = createRequest() as ApiAuthGuardRequest;
mockRequest.authMethod = AuthMethods.OAUTH_CLIENT;
mockRequest.organizationId = null;
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret, mockRequest);
expect(user).toBeDefined();
expect(user.id).toEqual(validOAuthUser.id);
expect(mockRequest.organizationId).toEqual(organization.id);
});
it("should return user associated with valid OAuth client for org2", async () => {
const mockRequest = createRequest() as ApiAuthGuardRequest;
mockRequest.authMethod = AuthMethods.OAUTH_CLIENT;
mockRequest.organizationId = null;
const user = await strategy.oAuthClientStrategy(oAuthClientTwo.id, oAuthClientTwo.secret, mockRequest);
expect(user).toBeDefined();
expect(user.id).toEqual(validOAuthUser.id);
expect(mockRequest.organizationId).toEqual(organizationTwo.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -2,12 +2,13 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -18,7 +19,11 @@ import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
export type ApiAuthGuardRequest = Request & {
authMethod: AuthMethods;
organizationId: number | null;
user: ApiAuthGuardUser;
};
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
@Injectable()
@@ -29,14 +34,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly oauthFlowService: OAuthFlowService,
private readonly tokensRepository: TokensRepository,
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeyRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository
private readonly profilesRepository: ProfilesRepository,
private readonly usersService: UsersService
) {
super();
}
async authenticate(request: Request & { authMethod: AuthMethods }) {
async authenticate(request: ApiAuthGuardRequest) {
try {
const { params } = request;
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
@@ -45,7 +51,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
if (oAuthClientId && oAuthClientSecret) {
request.authMethod = AuthMethods["OAUTH_CLIENT"];
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
}
if (bearerToken) {
@@ -53,7 +59,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? AuthMethods["API_KEY"]
: AuthMethods["ACCESS_TOKEN"];
return await this.authenticateBearerToken(bearerToken, requestOrigin);
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
}
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
@@ -61,7 +67,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
if (nextAuthToken) {
request.authMethod = AuthMethods["NEXT_AUTH"];
return await this.authenticateNextAuth(nextAuthToken);
return await this.authenticateNextAuth(nextAuthToken, request);
}
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
@@ -75,8 +81,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
}
}
async authenticateNextAuth(token: { email?: string | null }) {
const user = await this.nextAuthStrategy(token);
async authenticateNextAuth(token: { email?: string | null }, request: ApiAuthGuardRequest) {
const user = await this.nextAuthStrategy(token, request);
return this.success(this.getSuccessUser(user));
}
@@ -87,12 +93,16 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
};
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
async authenticateOAuthClient(
oAuthClientId: string,
oAuthClientSecret: string,
request: ApiAuthGuardRequest
) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret, request);
return this.success(this.getSuccessUser(user));
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string, request: ApiAuthGuardRequest) {
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
if (!client) {
@@ -115,14 +125,20 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("No user associated with the provided OAuth client");
}
request.organizationId = client.organizationId;
return user;
}
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
async authenticateBearerToken(
authString: string,
request: ApiAuthGuardRequest,
requestOrigin: string | undefined
) {
try {
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? await this.apiKeyStrategy(authString)
: await this.accessTokenStrategy(authString, requestOrigin);
? await this.apiKeyStrategy(authString, request)
: await this.accessTokenStrategy(authString, request, requestOrigin);
if (!user) {
return this.error(new UnauthorizedException("No user associated with the provided token"));
@@ -139,7 +155,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
}
}
async apiKeyStrategy(apiKey: string) {
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
const isLicenseValid = await this.deploymentsService.checkLicense();
if (!isLicenseValid) {
throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable");
@@ -163,10 +179,12 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
}
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
request.organizationId = keyData.teamId;
return user;
}
async accessTokenStrategy(accessToken: string, origin?: string) {
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
if (!accessTokenValid) {
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
@@ -190,10 +208,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
}
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
if (!user) {
throw new UnauthorizedException("User associated with the authentication api key not found.");
}
const organizationId = this.usersService.getUserMainOrgId(user) as number;
request.organizationId = organizationId;
return user;
}
async nextAuthStrategy(token: { email?: string | null }) {
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
if (!token.email) {
throw new UnauthorizedException("Email not found in the authentication token.");
}
@@ -202,6 +227,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found.");
}
const organizationId = this.usersService.getUserMainOrgId(user) as number;
request.organizationId = organizationId;
return user;
}
@@ -3,6 +3,7 @@ import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -26,8 +27,14 @@ import { Module } from "@nestjs/common";
}),
UsersModule,
],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository],
providers: [
BillingConfigService,
BillingService,
BillingRepository,
BillingProcessor,
ManagedOrganizationsBillingService,
],
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
controllers: [BillingController],
})
export class BillingModule {}
@@ -1,5 +1,5 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Process, Processor } from "@nestjs/bull";
import { Logger } from "@nestjs/common";
@@ -38,7 +38,7 @@ export class BillingRepository {
async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
try {
return this.dbWrite.prisma.platformBilling.update({
return this.dbWrite.prisma.platformBilling.updateMany({
where: {
subscriptionId: subId,
customerId: cusId,
@@ -141,7 +141,6 @@ describe("Platform Billing Controller (e2e)", () => {
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
@@ -3,7 +3,7 @@ import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/bi
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { InjectQueue } from "@nestjs/bull";
import {
@@ -0,0 +1,34 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class ManagedOrganizationsBillingService {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createManagedOrganizationBilling(managerOrgId: number, managedOrgId: number) {
const managerOrgBilling = await this.dbRead.prisma.platformBilling.findUnique({
where: { id: managerOrgId },
});
if (!managerOrgBilling) {
throw new NotFoundException("Manager organization billing not found.");
}
if (managerOrgBilling.plan !== PlatformPlan.SCALE) {
throw new NotFoundException("Manager organization billing plan is not SCALE.");
}
return this.dbWrite.prisma.platformBilling.create({
data: {
id: managedOrgId,
customerId: managerOrgBilling.customerId,
subscriptionId: managerOrgBilling.subscriptionId,
plan: managerOrgBilling.plan,
billingCycleStart: managerOrgBilling.billingCycleStart,
billingCycleEnd: managerOrgBilling.billingCycleEnd,
overdue: managerOrgBilling.overdue,
managerBillingId: managerOrgBilling.id,
},
});
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
@@ -3,8 +3,7 @@ import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { AuthModule } from "@/modules/auth/auth.module";
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -15,14 +14,27 @@ import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy";
import { ApiAuthMockStrategy } from "test/mocks/api-auth-mock.strategy";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CreateOAuthClientInput } from "@calcom/platform-types";
import {
APPS_READ,
APPS_WRITE,
BOOKING_READ,
BOOKING_WRITE,
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
PROFILE_READ,
PROFILE_WRITE,
SCHEDULE_READ,
SCHEDULE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import type { CreateOAuthClientInput, UpdateOAuthClientInput } from "@calcom/platform-types";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("OAuth Clients Endpoints", () => {
@@ -72,15 +84,15 @@ describe("OAuth Clients Endpoints", () => {
const userEmail = `oauth-clients-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule],
})
).compile();
const strategy = moduleRef.get(NextAuthStrategy);
expect(strategy).toBeInstanceOf(NextAuthMockStrategy);
const strategy = moduleRef.get(ApiAuthStrategy);
expect(strategy).toBeInstanceOf(ApiAuthMockStrategy);
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
@@ -123,6 +135,7 @@ describe("OAuth Clients Endpoints", () => {
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let user: User;
let org: Team;
@@ -130,19 +143,20 @@ describe("OAuth Clients Endpoints", () => {
const userEmail = `oauth-clients-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule],
})
).compile();
const strategy = moduleRef.get(NextAuthStrategy);
expect(strategy).toBeInstanceOf(NextAuthMockStrategy);
const strategy = moduleRef.get(ApiAuthStrategy);
expect(strategy).toBeInstanceOf(ApiAuthMockStrategy);
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
@@ -218,17 +232,17 @@ describe("OAuth Clients Endpoints", () => {
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
});
it(`/POST`, () => {
it(`/POST`, async () => {
const body: CreateOAuthClientInput = {
name: oAuthClientName,
redirectUris: ["http://test-oauth-client.com"],
permissions: 32,
permissions: ["BOOKING_READ", "PROFILE_WRITE"],
};
return request(app.getHttpServer())
.post("/api/v2/oauth-clients")
.send(body)
.expect(201)
.then((response) => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
@@ -239,6 +253,9 @@ describe("OAuth Clients Endpoints", () => {
clientId: responseBody.data.clientId,
clientSecret: responseBody.data.clientSecret,
};
const dbOAuthClient = await oAuthClientsRepositoryFixture.get(client.clientId);
expect(dbOAuthClient).toBeDefined();
expect(dbOAuthClient?.permissions).toEqual(BOOKING_READ + PROFILE_WRITE);
});
});
it(`/GET`, () => {
@@ -291,7 +308,18 @@ describe("OAuth Clients Endpoints", () => {
let membership: Membership;
let client: { clientId: string; clientSecret: string };
const oAuthClientName = `oauth-clients-owner-${randomString()}`;
const oAuthClientPermissions = 32;
const oAuthClientPermissions: CreateOAuthClientInput["permissions"] = [
"EVENT_TYPE_READ",
"EVENT_TYPE_WRITE",
"BOOKING_READ",
"BOOKING_WRITE",
"SCHEDULE_READ",
"SCHEDULE_WRITE",
"APPS_READ",
"APPS_WRITE",
"PROFILE_READ",
"PROFILE_WRITE",
];
beforeAll(async () => {
membership = await membershipFixtures.addUserToOrg(user, org, "OWNER", true);
@@ -301,13 +329,13 @@ describe("OAuth Clients Endpoints", () => {
const body: CreateOAuthClientInput = {
name: oAuthClientName,
redirectUris: ["http://test-oauth-client.com"],
permissions: 32,
permissions: ["*"],
};
return request(app.getHttpServer())
.post("/api/v2/oauth-clients")
.send(body)
.expect(201)
.then((response) => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
@@ -318,6 +346,20 @@ describe("OAuth Clients Endpoints", () => {
clientId: responseBody.data.clientId,
clientSecret: responseBody.data.clientSecret,
};
const dbOAuthClient = await oAuthClientsRepositoryFixture.get(client.clientId);
expect(dbOAuthClient).toBeDefined();
expect(dbOAuthClient?.permissions).toEqual(
EVENT_TYPE_READ +
EVENT_TYPE_WRITE +
BOOKING_READ +
BOOKING_WRITE +
SCHEDULE_READ +
SCHEDULE_WRITE +
APPS_READ +
APPS_WRITE +
PROFILE_READ +
PROFILE_WRITE
);
});
});
@@ -1,8 +1,7 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { GetOrgId } from "@/modules/auth/decorators/get-org-id/get-org-id.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
@@ -10,11 +9,9 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Body,
@@ -33,15 +30,14 @@ import {
BadRequestException,
} from "@nestjs/common";
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiOperation as DocsOperation,
ApiCreatedResponse as DocsCreatedResponse,
ApiTags,
} from "@nestjs/swagger";
import { User, MembershipRole } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
import { CreateOAuthClientInput, UpdateOAuthClientInput } from "@calcom/platform-types";
import { Pagination } from "@calcom/platform-types";
const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.
@@ -51,15 +47,16 @@ Second, make sure that the logged in user has organizationId set to pass the Org
path: "/v2/oauth-clients",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@ApiTags("OAuth Clients")
@UseGuards(ApiAuthGuard, OrganizationRolesGuard)
export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly oAuthClientsService: OAuthClientsService,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository,
private usersService: UsersService
private readonly teamsRepository: OrganizationsRepository
) {}
@Post("/")
@@ -71,10 +68,9 @@ export class OAuthClientsController {
type: CreateOAuthClientResponseDto,
})
async createOAuthClient(
@GetUser() user: UserWithProfile,
@GetOrgId() organizationId: number,
@Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> {
const organizationId = this.usersService.getUserMainOrgId(user) as number;
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
@@ -84,14 +80,11 @@ export class OAuthClientsController {
throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
}
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
const oAuthClientCredentials = await this.oAuthClientsService.createOAuthClient(organizationId, body);
return {
status: SUCCESS_STATUS,
data: {
clientId: id,
clientSecret: secret,
},
data: oAuthClientCredentials,
};
}
@@ -99,10 +92,8 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = this.usersService.getUserMainOrgId(user) as number;
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
async getOAuthClients(@GetOrgId() organizationId: number): Promise<GetOAuthClientsResponseDto> {
const clients = await this.oAuthClientsService.getOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients };
}
@@ -112,11 +103,7 @@ export class OAuthClientsController {
@DocsOperation({ description: AUTH_DOCUMENTATION })
@UseGuards(OAuthClientGuard)
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
if (!client) {
throw new NotFoundException(`OAuth client with ID ${clientId} not found`);
}
const client = await this.oAuthClientsService.getOAuthClientById(clientId);
return { status: SUCCESS_STATUS, data: client };
}
@@ -149,8 +136,7 @@ export class OAuthClientsController {
@Body() body: UpdateOAuthClientInput
): Promise<GetOAuthClientResponseDto> {
this.logger.log(`For client ${clientId} updating OAuth Client with data: ${JSON.stringify(body)}`);
const client = await this.oauthClientRepository.updateOAuthClient(clientId, body);
const client = await this.oAuthClientsService.updateOAuthClient(clientId, body);
return { status: SUCCESS_STATUS, data: client };
}
@@ -161,7 +147,7 @@ export class OAuthClientsController {
@UseGuards(OAuthClientGuard)
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
const client = await this.oAuthClientsService.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
@@ -1,53 +1,9 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
ValidateNested,
IsEnum,
IsString,
IsNumber,
IsOptional,
IsDate,
IsNotEmptyObject,
} from "class-validator";
import { ValidateNested, IsEnum, IsNotEmptyObject } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class PlatformOAuthClientDto {
@ApiProperty({ example: "clsx38nbl0001vkhlwin9fmt0" })
@IsString()
id!: string;
@ApiProperty({ example: "MyClient" })
@IsString()
name!: string;
@ApiProperty({ example: "secretValue" })
@IsString()
secret!: string;
@ApiProperty({ example: 3 })
@IsNumber()
permissions!: number;
@ApiPropertyOptional({ example: "https://example.com/logo.png" })
@IsOptional()
@IsString()
logo!: string | null;
@ApiProperty({ example: ["https://example.com/callback"] })
@IsArray()
@IsString({ each: true })
redirectUris!: string[];
@ApiProperty({ example: 1 })
@IsNumber()
organizationId!: number;
@ApiProperty({ example: "2024-03-23T08:33:21.851Z", type: Date })
@IsDate()
createdAt!: Date;
}
import { PlatformOAuthClientDto } from "@calcom/platform-types";
export class GetOAuthClientResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -1,9 +1,9 @@
import { PlatformOAuthClientDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, ValidateNested, IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { PlatformOAuthClientDto } from "@calcom/platform-types";
export class GetOAuthClientsResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -1,6 +1,7 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ApiAuthGuardRequest, ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Injectable,
CanActivate,
@@ -15,25 +16,35 @@ export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: ApiAuthGuardUser }>();
const request = context.switchToHttp().getRequest<ApiAuthGuardRequest>();
const organizationId = this.getOrganizationId(context);
const user: ApiAuthGuardUser = request.user;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const oAuthClientId = request.params.clientId;
if (!oAuthClientId) {
throw new ForbiddenException("No OAuth client associated with the request.");
throw new ForbiddenException("OAuthClientGuard - No OAuth client associated with the request.");
}
if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user.");
throw new ForbiddenException("OAuthClientGuard - No organization associated with the user.");
}
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
if (!oAuthClient) {
throw new NotFoundException("OAuth client not found.");
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
}
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
}
getOrganizationId(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user;
const authMethodOrganizationId = request.organizationId;
if (authMethodOrganizationId) return authMethodOrganizationId;
const userOrganizationId = user ? this.usersService.getUserMainOrgId(user) : null;
return userOrganizationId;
}
}
@@ -8,9 +8,12 @@ import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oaut
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -18,6 +21,7 @@ import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { Global, Module } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
@Global()
@Module({
@@ -40,6 +44,10 @@ import { Global, Module } from "@nestjs/common";
OAuthFlowService,
OAuthClientUsersService,
OrganizationsTeamsService,
OAuthClientsService,
OAuthClientsInputService,
OAuthClientsOutputService,
JwtService,
],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository],
@@ -1,24 +1,19 @@
import { JwtService } from "@/modules/jwt/jwt.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { PlatformOAuthClient } from "@prisma/client";
import type { CreateOAuthClientInput } from "@calcom/platform-types";
import type { PlatformOAuthClient, Prisma } from "@prisma/client";
@Injectable()
export class OAuthClientRepository {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private jwtService: JwtService
) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createOAuthClient(organizationId: number, data: CreateOAuthClientInput) {
async createOAuthClient(
organizationId: number,
data: Omit<Prisma.PlatformOAuthClientCreateInput, "organization">
) {
return this.dbWrite.prisma.platformOAuthClient.create({
data: {
...data,
secret: this.jwtService.sign(data),
organizationId,
},
});
@@ -86,7 +81,7 @@ export class OAuthClientRepository {
async updateOAuthClient(
clientId: string,
updateData: Partial<CreateOAuthClientInput>
updateData: Prisma.PlatformOAuthClientUpdateInput
): Promise<PlatformOAuthClient> {
return this.dbWrite.prisma.platformOAuthClient.update({
where: { id: clientId },
@@ -1,6 +1,6 @@
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -0,0 +1,29 @@
import { JwtService } from "@/modules/jwt/jwt.service";
import { Injectable } from "@nestjs/common";
import { PERMISSION_MAP } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
@Injectable()
export class OAuthClientsInputService {
constructor(private readonly jwtService: JwtService) {}
transformCreateOAuthClientInput(input: CreateOAuthClientInput) {
const transformed = {
...input,
permissions: this.transformPermissions(input.permissions),
};
return {
...transformed,
secret: this.jwtService.sign(transformed),
};
}
transformPermissions(permissions: Array<keyof typeof PERMISSION_MAP | "*">): number {
const values = permissions.includes("*")
? Object.values(PERMISSION_MAP)
: permissions.map((p) => PERMISSION_MAP[p as keyof typeof PERMISSION_MAP]);
return values.reduce((acc, val) => acc | val, 0);
}
}
@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { PlatformOAuthClient } from "@prisma/client";
import { PERMISSIONS, PERMISSION_MAP } from "@calcom/platform-constants";
import { PlatformOAuthClientDto } from "@calcom/platform-types";
@Injectable()
export class OAuthClientsOutputService {
transformPermissions(permissionsNumber: number): Array<keyof typeof PERMISSION_MAP> {
const permissionNumbers = PERMISSIONS.filter(
(permission) => (permissionsNumber & permission) === permission
);
return permissionNumbers.map(
(permission) =>
Object.entries(PERMISSION_MAP).find(
([_, value]) => value === permission
)?.[0] as keyof typeof PERMISSION_MAP
);
}
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
return {
id: client.id,
name: client.name,
secret: client.secret,
permissions: this.transformPermissions(client.permissions),
logo: client.logo,
redirectUris: client.redirectUris,
organizationId: client.organizationId,
createdAt: client.createdAt,
bookingRedirectUri: client.bookingRedirectUri ?? undefined,
bookingCancelRedirectUri: client.bookingCancelRedirectUri ?? undefined,
bookingRescheduleRedirectUri: client.bookingRescheduleRedirectUri ?? undefined,
areEmailsEnabled: client.areEmailsEnabled,
};
}
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
return clients.map((client) => this.transformOAuthClient(client));
}
}
@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateOAuthClientInput, UpdateOAuthClientInput } from "@calcom/platform-types";
import { OAuthClientRepository } from "../../oauth-client.repository";
import { OAuthClientsInputService } from "./oauth-clients-input.service";
import { OAuthClientsOutputService } from "./oauth-clients-output.service";
@Injectable()
export class OAuthClientsService {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly oauthClientsInputService: OAuthClientsInputService,
private readonly oauthClientsOutputService: OAuthClientsOutputService
) {}
async createOAuthClient(organizationId: number, input: CreateOAuthClientInput) {
const transformedInput = this.oauthClientsInputService.transformCreateOAuthClientInput(input);
const { id, secret } = await this.oauthClientRepository.createOAuthClient(
organizationId,
transformedInput
);
return {
clientId: id,
clientSecret: secret,
};
}
async getOAuthClients(organizationId: number) {
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return this.oauthClientsOutputService.transformOAuthClients(clients);
}
async getOAuthClientById(clientId: string) {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
if (!client) {
throw new NotFoundException(`OAuth client with ID ${clientId} not found`);
}
return this.oauthClientsOutputService.transformOAuthClient(client);
}
async updateOAuthClient(clientId: string, input: UpdateOAuthClientInput) {
const client = await this.oauthClientRepository.updateOAuthClient(clientId, input);
return this.oauthClientsOutputService.transformOAuthClient(client);
}
async deleteOAuthClient(clientId: string) {
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
return this.oauthClientsOutputService.transformOAuthClient(client);
}
}
@@ -1,7 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/update-organization-attribute.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -6,16 +6,16 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output";
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/update-organization-attribute.input";
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/attributes/index/outputs/create-organization-attributes.output";
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/attributes/index/outputs/delete-organization-attributes.output";
import {
GetOrganizationAttributesOutput,
GetSingleAttributeOutput,
} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output";
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
} from "@/modules/organizations/attributes/index/outputs/get-organization-attributes.output";
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/attributes/index/outputs/update-organization-attributes.output";
import { OrganizationAttributesService } from "@/modules/organizations/attributes/index/services/organization-attributes.service";
import {
Body,
Controller,
@@ -1,4 +1,4 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { AttributeType } from "@prisma/client";
import { Type } from "class-transformer";
@@ -1,5 +1,5 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/update-organization-attribute.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@@ -1,5 +1,5 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Attribute } from "@/modules/organizations/attributes/index/outputs/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Attribute } from "@/modules/organizations/attributes/index/outputs/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Attribute } from "@/modules/organizations/attributes/index/outputs/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsOptional, ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Attribute } from "@/modules/organizations/attributes/index/outputs/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,6 +1,6 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/attributes/index/inputs/update-organization-attribute.input";
import { OrganizationAttributesRepository } from "@/modules/organizations/attributes/index/organization-attributes.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
@@ -1,6 +1,6 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
@@ -1,8 +1,8 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -6,19 +6,19 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import {
AssignOptionUserOutput,
UnassignOptionUserOutput,
} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output";
import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output";
import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output";
import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output";
import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output";
import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -30,7 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Attributes / Options")
export class OrganizationsOptionsAttributesController {
export class OrganizationsAttributesOptionsController {
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
@Roles("ORG_ADMIN")
@@ -1,5 +1,5 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,4 +1,4 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,4 +1,4 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
@@ -1,5 +1,5 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
@@ -1,9 +1,9 @@
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { OrganizationAttributesService } from "@/modules/organizations/attributes/index/services/organization-attributes.service";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
@@ -10,10 +10,10 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/event-types/services/organizations-event-types.service";
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
import { CreateTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/create-team-event-type.output";
import { DeleteTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/delete-team-event-type.output";
import { GetTeamEventTypeOutput } from "@/modules/teams/event-types/outputs/get-team-event-type.output";
@@ -1,5 +1,5 @@
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
import { Injectable, PipeTransform } from "@nestjs/common";
import { plainToClass } from "class-transformer";
@@ -1,6 +1,6 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { DatabaseTeamEventType } from "@/modules/organizations/services/event-types/output.service";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { UsersService } from "@/modules/users/services/users.service";
@@ -4,6 +4,8 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsRepository {
constructor(
@@ -21,6 +23,17 @@ export class OrganizationsRepository {
});
}
async findByIds(organizationIds: number[]) {
return this.dbRead.prisma.team.findMany({
where: {
id: {
in: organizationIds,
},
isOrganization: true,
},
});
}
async findByIdIncludeBilling(orgId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
@@ -132,6 +145,25 @@ export class OrganizationsRepository {
});
}
async update(organizationId: number, data: Prisma.TeamUpdateInput) {
return this.dbWrite.prisma.team.update({
where: {
id: organizationId,
isOrganization: true,
},
data,
});
}
async delete(organizationId: number) {
return this.dbWrite.prisma.team.delete({
where: {
id: organizationId,
isOrganization: true,
},
});
}
async findOrgBySlug(slug: string) {
return this.dbRead.prisma.team.findFirst({
where: {
@@ -1,4 +1,4 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
@@ -1,7 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -7,15 +7,15 @@ import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membersh
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
import { CreateOrgMembershipOutput } from "@/modules/organizations/outputs/organization-membership/create-membership.output";
import { DeleteOrgMembership } from "@/modules/organizations/outputs/organization-membership/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/outputs/organization-membership/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/outputs/organization-membership/get-membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/outputs/organization-membership/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input";
import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/outputs/create-membership.output";
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import {
Controller,
UseGuards,
@@ -1,9 +1,9 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
@Injectable()
export class OrganizationsMembershipRepository {
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -1,5 +1,5 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
@@ -5,43 +5,44 @@ import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/controllers/pipes/event-types/team-event-types-response.transformer";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersOOOController } from "@/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller";
import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository";
import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service";
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { OrganizationsAttributesController } from "@/modules/organizations/attributes/index/controllers/organizations-attributes.controller";
import { OrganizationAttributesRepository } from "@/modules/organizations/attributes/index/organization-attributes.repository";
import { OrganizationAttributesService } from "@/modules/organizations/attributes/index/services/organization-attributes.service";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/event-types/services/organizations-event-types.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms-responses.module";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
import { OrganizationsUsersOOOController } from "@/modules/organizations/users/ooo/controllers/organizations-users-ooo-controller";
import { OrgUsersOOORepository } from "@/modules/organizations/users/ooo/organizations-users-ooo.repository";
import { OrgUsersOOOService } from "@/modules/organizations/users/ooo/services/organization-users-ooo.service";
import { OrganizationsWebhooksController } from "@/modules/organizations/webhooks/controllers/organizations-webhooks.controller";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhooks/organizations-webhooks.repository";
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -63,6 +64,7 @@ import { Module } from "@nestjs/common";
EventTypesModule_2024_06_14,
TeamsEventTypesModule,
TeamsModule,
OrganizationsOrganizationsModule,
OrganizationsTeamsRoutingFormsModule,
],
providers: [
@@ -126,7 +128,7 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsAttributesController,
OrganizationsOptionsAttributesController,
OrganizationsAttributesOptionsController,
OrganizationsWebhooksController,
OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController,
@@ -0,0 +1,16 @@
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsObject, IsOptional, IsString, Length } from "class-validator";
export class CreateOrganizationInput extends RefreshApiKeyInput {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
readonly name!: string;
@IsOptional()
@IsObject()
@ApiPropertyOptional({ type: Object })
readonly metadata?: Record<string, unknown>;
}
@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString, Length } from "class-validator";
export class UpdateOrganizationInput {
@IsString()
@IsOptional()
@Length(1)
@ApiPropertyOptional({ description: "Name of the organization", example: "CalTeam" })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly metadata?: string;
}
@@ -0,0 +1,44 @@
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 ManagedOrganizationsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createManagedOrganization(managerOrganizationId: number, data: Prisma.TeamCreateInput) {
return this.dbWrite.prisma.team.create({
data: {
...data,
managedOrganization: {
create: {
managerOrganization: {
connect: { id: managerOrganizationId },
},
},
},
},
});
}
async getByManagerManagedIds(managerOrganizationId: number, managedOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findUnique({
where: {
managerOrganizationId_managedOrganizationId: {
managerOrganizationId,
managedOrganizationId,
},
},
});
}
async getByManagerOrganizationId(managerOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findMany({
where: {
managerOrganizationId,
},
});
}
}
@@ -0,0 +1,449 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { getEnv } from "@/env";
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import {
ManagedOrganizationWithApiKeyOutput,
ManagedOrganizationOutput,
} from "@/modules/organizations/organizations/outputs/managed-organization.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformBilling, User } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import { DateTime } from "luxon";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { ManagedOrganizationsRepositoryFixture } from "test/fixtures/repository/managed-organizations.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import {
APPS_READ,
APPS_WRITE,
BOOKING_READ,
BOOKING_WRITE,
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
PROFILE_READ,
PROFILE_WRITE,
SCHEDULE_READ,
SCHEDULE_WRITE,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Organizations Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let managedOrganizationsRepositoryFixture: ManagedOrganizationsRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let managerOrg: Team;
let managedOrg: ManagedOrganizationWithApiKeyOutput;
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
let managerOrgAdmin: User;
let managerOrgAdminApiKey: string;
let managerOrgBilling: PlatformBilling;
let managedOrgApiKey: string;
let managedOrgOAuthClientId: string;
let managedOrgOAuthClientSecret: string;
const createOAuthClientBody: CreateOAuthClientInput = {
name: "OAuth client for managed organization",
redirectUris: ["http://localhost:4321"],
permissions: ["*"],
};
const newDate = new Date(2035, 0, 9, 15, 0, 0);
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
managedOrganizationsRepositoryFixture = new ManagedOrganizationsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
managerOrgAdmin = await userRepositoryFixture.create({
email: managerOrgAdminEmail,
username: managerOrgAdminEmail,
});
managerOrg = await organizationsRepositoryFixture.create({
name: `organizations-organizations-organization-${randomString()}`,
isOrganization: true,
isPlatform: true,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: managerOrgAdminEmail,
user: { connect: { id: managerOrgAdmin.id } },
organization: { connect: { id: managerOrg.id } },
movedFromUser: { connect: { id: managerOrgAdmin.id } },
});
managerOrgBilling = await platformBillingRepositoryFixture.create(managerOrg.id, "SCALE");
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: managerOrgAdmin.id } },
team: { connect: { id: managerOrg.id } },
accepted: true,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(
managerOrgAdmin.id,
null,
managerOrg.id
);
managerOrgAdminApiKey = `cal_test_${keyString}`;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
advanceTo(newDate);
await app.init();
});
afterAll(() => {
clear();
});
it("should create managed organization", async () => {
const suffix = randomString();
const body: CreateOrganizationInput = {
name: `organizations organizations org ${suffix}`,
metadata: { key: "value" },
};
return request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
managedOrg = responseBody.data;
expect(managedOrg?.id).toBeDefined();
expect(managedOrg?.name).toEqual(body.name);
expect(managedOrg?.metadata).toEqual(body.metadata);
expect(managedOrg?.apiKey).toBeDefined();
// note(Lauris): check that managed organization is correctly setup in database
const managedOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg.id);
expect(managedOrgInDb).toBeDefined();
expect(managedOrgInDb?.id).toEqual(managedOrg.id);
expect(managedOrgInDb?.isPlatform).toEqual(true);
expect(managedOrgInDb?.isOrganization).toEqual(true);
expect(managedOrgInDb?.managedOrganization?.managedOrganizationId).toEqual(managedOrg.id);
expect(managedOrgInDb?.managedOrganization?.managerOrganizationId).toEqual(managerOrg.id);
// note(Lauris): check that manager organization is correctly setup in database
const managerOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managerOrg.id);
expect(managerOrgInDb).toBeDefined();
expect(managerOrgInDb?.id).toEqual(managerOrg.id);
expect(managerOrgInDb?.isPlatform).toEqual(true);
expect(managerOrgInDb?.isOrganization).toEqual(true);
expect(managerOrgInDb?.managedOrganization).toEqual(null);
expect(managerOrgInDb?.managedOrganizations?.length).toEqual(1);
expect(managerOrgInDb?.managedOrganizations?.[0]?.managedOrganizationId).toEqual(managedOrg.id);
expect(managerOrgInDb?.managedOrganizations?.[0]?.managerOrganizationId).toEqual(managerOrg.id);
// note(Lauris): test that auth user who made request to create managed organization is OWNER of it
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
managerOrgAdmin.id,
managedOrg.id
);
expect(membership?.role ?? "").toEqual("OWNER");
expect(membership?.accepted).toEqual(true);
// note(Lauris): test that auth user who made request to create managed organization has profile in it
const profile = await profilesRepositoryFixture.findByOrgIdUserId(managedOrg.id, managerOrgAdmin.id);
expect(profile).toBeDefined();
expect(profile?.id).toBeDefined();
expect(profile?.username).toEqual(managerOrgAdmin.username);
// note(Lauris): check that platform billing is setup correctly for manager and managed orgs
const managerOrgBilling = await platformBillingRepositoryFixture.get(managerOrg.id);
expect(managerOrgBilling).toBeDefined();
expect(managerOrgBilling?.id).toBeDefined();
const customerId = managerOrgBilling?.customerId;
expect(customerId).toBeDefined();
if (!customerId) {
throw new Error(
"organizations-organizations.controller.e2e-spec.ts: PlatformBilling customerId is not defined"
);
}
const subscriptionId = managerOrgBilling?.subscriptionId;
expect(subscriptionId).toBeDefined();
if (!subscriptionId) {
throw new Error(
"organizations-organizations.controller.e2e-spec.ts: PlatformBilling subscriptionId is not defined"
);
}
const plan = managerOrgBilling?.plan;
expect(plan).toEqual("SCALE");
const managedOrgBilling = await platformBillingRepositoryFixture.get(managedOrg.id);
expect(managedOrgBilling).toBeDefined();
expect(managedOrgBilling?.customerId).toEqual(customerId);
expect(managedOrgBilling?.subscriptionId).toEqual(subscriptionId);
expect(managedOrgBilling?.plan).toEqual(plan);
expect(managedOrgBilling?.managerBillingId).toEqual(managerOrgBilling?.id);
expect(managerOrgBilling?.managedBillings?.length).toEqual(1);
expect(managerOrgBilling?.managedBillings?.[0]?.id).toEqual(managedOrgBilling?.id);
const billings = await platformBillingRepositoryFixture.getByCustomerSubscriptionIds(
customerId,
subscriptionId
);
expect(billings).toBeDefined();
// note(Lauris): manager and manager organizaitons billings because managed organization client and subscription ids are same as manager organization billing row.
expect(billings?.length).toEqual(2);
// note(Lauris): check that in database api key is generated for managed org
const managedOrgApiKeys = await apiKeysRepositoryFixture.getTeamApiKeys(managedOrg.id);
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
expect(managedOrgApiKeys?.[0]?.note).toEqual(
`Managed organization API key. ManagerOrgId: ${managerOrg.id}. ManagedOrgId: ${managedOrg.id}`
);
managedOrgApiKey = managedOrg?.apiKey;
});
});
it("should get managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrg = responseBody.data;
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
});
});
it("should get managed organizations", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
const responseManagedOrg = responseManagedOrgs[0];
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
});
});
it("should update managed organization ", async () => {
const suffix = randomString();
const newOrgName = `new organizations organizations org ${suffix}`;
return request(app.getHttpServer())
.patch(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send({
name: newOrgName,
} satisfies UpdateOrganizationInput)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrg = responseBody.data;
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(newOrgName);
const managedOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg.id);
expect(managedOrgInDb).toBeDefined();
expect(managedOrgInDb?.name).toEqual(newOrgName);
managedOrg = { ...managedOrg, name: newOrgName };
});
});
it("should refresh api key for managed organization with a custom duration", async () => {
return request(app.getHttpServer())
.post(`/v2/api-keys/refresh`)
.send({ apiKeyDaysValid: 60 })
.set("Authorization", `Bearer ${managedOrgApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: RefreshApiKeyOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseData = responseBody.data;
const newApiKey = responseData?.apiKey;
expect(newApiKey).toBeDefined();
expect(newApiKey).not.toEqual(managedOrgApiKey);
const managedOrgApiKeys = await apiKeysRepositoryFixture.getTeamApiKeys(managedOrg.id);
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
expect(managedOrgApiKeys?.[0]?.note).toEqual(
`Managed organization API key. ManagerOrgId: ${managerOrg.id}. ManagedOrgId: ${managedOrg.id}`
);
managedOrgApiKey = newApiKey;
});
});
it("should create OAuth client for managed organization", async () => {
return request(app.getHttpServer())
.post(`/v2/oauth-clients`)
.send(createOAuthClientBody)
.set("Authorization", `Bearer ${managedOrgApiKey}`)
.expect(201)
.then(async (response) => {
const responseBody: CreateOAuthClientResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseData = responseBody.data;
const clientId = responseData?.clientId;
const clientSecret = responseData?.clientSecret;
expect(clientId).toBeDefined();
expect(clientSecret).toBeDefined();
const managedOrgOAuthClients = await oAuthClientsRepositoryFixture.getByOrgId(managedOrg.id);
expect(managedOrgOAuthClients?.length).toEqual(1);
expect(managedOrgOAuthClients?.[0]?.id).toBeDefined();
expect(managedOrgOAuthClients?.[0]?.id).toEqual(clientId);
expect(managedOrgOAuthClients?.[0]?.secret).toEqual(clientSecret);
expect(managedOrgOAuthClients?.[0]?.name).toEqual(createOAuthClientBody.name);
expect(managedOrgOAuthClients?.[0]?.redirectUris).toEqual(createOAuthClientBody.redirectUris);
expect(managedOrgOAuthClients?.[0]?.permissions).toEqual(
EVENT_TYPE_READ +
EVENT_TYPE_WRITE +
BOOKING_READ +
BOOKING_WRITE +
SCHEDULE_READ +
SCHEDULE_WRITE +
APPS_READ +
APPS_WRITE +
PROFILE_READ +
PROFILE_WRITE
);
managedOrgOAuthClientId = clientId;
managedOrgOAuthClientSecret = clientSecret;
});
});
it("should fetch OAuth client for managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/oauth-clients/${managedOrgOAuthClientId}`)
.set(X_CAL_SECRET_KEY, managedOrgOAuthClientSecret)
.expect(200)
.then(async (response) => {
const responseBody: GetOAuthClientResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseData = responseBody.data;
expect(responseData?.id).toEqual(managedOrgOAuthClientId);
expect(responseData?.secret).toEqual(managedOrgOAuthClientSecret);
expect(responseData?.name).toEqual(createOAuthClientBody.name);
expect(responseData?.redirectUris).toEqual(createOAuthClientBody.redirectUris);
expect(responseData?.permissions).toEqual([
"EVENT_TYPE_READ",
"EVENT_TYPE_WRITE",
"BOOKING_READ",
"BOOKING_WRITE",
"SCHEDULE_READ",
"SCHEDULE_WRITE",
"APPS_READ",
"APPS_WRITE",
"PROFILE_READ",
"PROFILE_WRITE",
]);
});
});
it("should delete managed organization ", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrg = responseBody.data;
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.id).toEqual(managedOrg.id);
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
const managedOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg.id);
expect(managedOrgInDb).toEqual(null);
const billings = await platformBillingRepositoryFixture.getByCustomerSubscriptionIds(
managerOrgBilling.customerId,
managerOrgBilling.subscriptionId!
);
expect(billings).toBeDefined();
// note(Lauris): only manager billing is left
expect(billings?.length).toEqual(1);
const managerOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managerOrg.id);
expect(managerOrgInDb).toBeDefined();
expect(managerOrgInDb?.id).toEqual(managerOrg.id);
expect(managerOrgInDb?.managedOrganizations?.length).toEqual(0);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(managerOrgAdmin.email);
await organizationsRepositoryFixture.delete(managerOrg.id);
await app.close();
});
});

Some files were not shown because too many files have changed in this diff Show More