feat: platform team event-types (#15928)

* fix: dont check OrganizationSettings.orgAutoAcceptEmail uniqueness for platform teams

* feat: display organizationId in OAuthClientCard

* feat: ApiAuthStrategy handle oauth credentials

* refactor: clean up ApiAuthStrategy test

* refactor: use ApiAuthGuard in OauthClientsUsersController and OAuthFlowController

* fix: copying org id

* refactor: more specific error message when api auth

* refactor: auto accept team creator membership

* feat: useTeamEventType and useTeamEventTypes hooks

* refactor: make team event-types public & searchable by eventSlug

* feat: include host name in team event-types hosts response

* fix: isFixed=true by default for COLLECTIVE event hosts

* fix: useTeamEventType eventSlug access

* feat: BookerPlatformWrapper enable team events by exposting orgId and teamId props

* refactor: provide orgId in atoms context

* refactor: use orgId from context in team event-types hooks

* refactor: return teams in useMe

* chore: examples app teams setup

* Revert "refactor: return teams in useMe"

This reverts commit de992ddc9af6ee9a2111938069f5b9c34cc2d8ea.

* Revert "chore: examples app teams setup"

This reverts commit 0766aa21acc25efa2361d38c3f87ddba773a0245.

* feat: useTeams hook

* chore: setup examples app with team event-type

* fix: small fixes

* swagger

* Revert "refactor: provide orgId in atoms context"

This reverts commit f053a498ee6f8fa8ece5ec8d8630c59eda8873e3.

* feat: orgId in atoms context

* feat: PlatformBilling guard

* chore: delete test of the deleted oauth-client-credentials.guard

* refactor: org event-types collective events isFixed always true and priority medium

* refactor: org event-types COLLECTIVE response ignore isFixed and priority as they are same

* fix: organizations event-types e2e

* fix: billing guard

* refactor: tests cleanup

* fix: platform plan guard spec

* refactor: e2e test cleanup

* seed error if not team

* refactor: rename authenticateApiKey to authenticateBearerToken

* refactor: transforming response hosts

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

* refactor: rename findUniqueByMatchingAutoAcceptEmail to findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2024-07-30 16:58:39 +02:00
committed by GitHub
co-authored by Morgan
parent 06bb7055ac
commit 2f946fc5da
59 changed files with 1515 additions and 412 deletions
@@ -22,4 +22,7 @@ export class MeOutput {
@IsString()
timeZone!: string;
@IsInt()
organizationId!: number | null;
}
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -0,0 +1,4 @@
import type { PlatformPlanType } from "@/modules/billing/types";
import { Reflector } from "@nestjs/core";
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
@@ -0,0 +1,123 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
describe("PlatformPlanGuard", () => {
let guard: PlatformPlanGuard;
let reflector: Reflector;
let organizationsRepository: OrganizationsRepository;
let redisService: RedisService;
const mockContext = createMockExecutionContext({
params: { teamId: "1", orgId: "1" },
user: { id: "1" },
});
beforeEach(async () => {
reflector = new Reflector();
organizationsRepository = createMock<OrganizationsRepository>();
redisService = createMock<RedisService>({
redis: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(null),
},
});
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
});
it("should be defined", () => {
expect(guard).toBeDefined();
});
it("should return true", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "SCALE",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization does not exist", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the organization is not platform", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: undefined,
});
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the organization has no subscription", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: null,
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false if the user has a lower plan than required", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
isPlatform: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
platformBilling: {
subscriptionId: "sub_123",
plan: "STARTER",
},
});
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return true if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if the result is cached in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
params: context.params,
user: context.user,
}),
}),
});
}
});
@@ -0,0 +1,79 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { PlatformPlanType } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
@Injectable()
export class PlatformPlanGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
}
let canAccess = false;
if (user && orgId) {
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = team?.isPlatform;
const hasSubscription = team?.platformBilling?.subscriptionId;
if (!team) {
canAccess = false;
} else if (!isPlatform) {
canAccess = true;
} else if (!hasSubscription) {
canAccess = false;
} else {
canAccess = hasMinimumPlan({
currentPlan: team.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
type HasMinimumPlanProp = {
currentPlan: PlatformPlanType;
minimumPlan: PlatformPlanType;
plans: PlatformPlanType[];
};
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
return currentPlanIndex >= minimumPlanIndex;
}
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
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 { UsersRepository } from "@/modules/users/users.repository";
import { ExecutionContext, HttpException } from "@nestjs/common";
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { MockedRedisService } from "test/mocks/mock-redis-service";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { ApiAuthStrategy } from "./api-auth.strategy";
describe("ApiAuthStrategy", () => {
@@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => {
let oAuthClient: PlatformOAuthClient;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
const validApiKeyEmail = "api-key-user-email@example.com";
const validAccessTokenEmail = "access-token-user-email@example.com";
const validOAuthEmail = "oauth-user@example.com";
let validApiKeyUser: User;
let validAccessTokenUser: User;
let validOAuthUser: User;
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
isGlobal: true,
load: [appConfig],
}),
ProfilesModule,
],
providers: [
MockedRedisService,
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
validApiKeyUser = await userRepositoryFixture.create({
email: validApiKeyEmail,
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
validAccessTokenUser = await userRepositoryFixture.create({
email: validAccessTokenEmail,
});
validOAuthUser = await userRepositoryFixture.create({
email: validOAuthEmail,
});
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: validOAuthEmail,
user: { connect: { id: validOAuthUser.id } },
organization: { connect: { id: organization.id } },
});
const data = {
logo: "logo-url",
name: "name",
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
oAuthClient.id
);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer ${accessToken}`,
},
get: (key: string) =>
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.accessTokenStrategy(accessToken);
await expect(user).toBeDefined();
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
expect(user).toBeDefined();
expect(user?.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => {
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: `Bearer cal_test_${keyString}`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.apiKeyStrategy(keyString);
await expect(user).toBeDefined();
if (user) expect(user.id).toEqual(validApiKeyUser.id);
expect(user).toBeDefined();
expect(user?.id).toEqual(validApiKeyUser.id);
});
it("should return user associated with valid OAuth client", async () => {
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
expect(user).toBeDefined();
expect(user.id).toEqual(validOAuthUser.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -158,7 +162,55 @@ describe("ApiAuthStrategy", () => {
}
});
it("should throw 401 if Authorization header does not contain auth token", async () => {
it("should throw 401 if OAuth ID is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
[X_CAL_SECRET_KEY]: `secret`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if OAuth secret is invalid", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
[X_CAL_SECRET_KEY]: `gibberish`,
},
get: (key: string) =>
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
}),
}),
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
try {
await strategy.authenticate(request);
} catch (error) {
if (error instanceof HttpException) {
expect(error.getStatus()).toEqual(401);
}
}
});
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
await userRepositoryFixture.delete(validApiKeyUser.id);
await userRepositoryFixture.delete(validAccessTokenUser.id);
await userRepositoryFixture.delete(validOAuthUser.id);
await teamRepositoryFixture.delete(organization.id);
module.close();
});
});
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-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 { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
@@ -20,19 +22,74 @@ 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: ApiKeyRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository
) {
super();
}
async authenticate(request: Request) {
const authString = request.get("Authorization")?.replace("Bearer ", "");
if (!authString) {
return this.error(new UnauthorizedException("No Authorization header provided"));
try {
const { params } = request;
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
if (oAuthClientId && oAuthClientSecret) {
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
}
if (bearerToken) {
const requestOrigin = request.get("Origin");
return await this.authenticateBearerToken(bearerToken, requestOrigin);
}
throw new UnauthorizedException(
"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"
);
} catch (err) {
if (err instanceof Error) {
return this.error(err);
}
return this.error(
new InternalServerErrorException("An error occurred while authenticating the request")
);
}
}
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
return this.success(user);
}
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
if (!client) {
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
}
const requestOrigin = request.get("Origin");
if (client.secret !== oAuthClientSecret) {
throw new UnauthorizedException("Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
if (!platformCreatorId) {
throw new UnauthorizedException("No owner ID found for this OAuth client");
}
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
if (!user) {
throw new UnauthorizedException("No user associated with the provided OAuth client");
}
return user;
}
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
try {
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? await this.apiKeyStrategy(authString)
+2
View File
@@ -4,3 +4,5 @@ export enum PlatformPlan {
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -18,6 +18,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let postResponseData: CreateUserResponse;
const platformAdminEmail = "platform-sensei@mail.com";
let platformAdmin: User;
const userEmail = "oauth-client-user@gmail.com";
const userTimeZone = "Europe/Rome";
@@ -98,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: platformAdmin.id } },
organization: { connect: { id: organization.id } },
});
await app.init();
});
@@ -277,6 +293,11 @@ describe("OAuth Client Users Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(platformAdmin.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -1,11 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
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";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
@@ -37,7 +37,7 @@ import { Pagination } from "@calcom/platform-types";
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard)
@DocsTags("Managed users")
export class OAuthClientUsersController {
private readonly logger = new Logger("UserController");
@@ -15,6 +15,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { withNextAuth } from "test/utils/withNextAuth";
@@ -58,6 +59,7 @@ describe("OAuthFlow Endpoints", () => {
let usersRepositoryFixtures: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let user: User;
let organization: Team;
@@ -83,11 +85,19 @@ describe("OAuthFlow Endpoints", () => {
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
});
organization = await organizationsRepositoryFixture.create({ name: "organization" });
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -1,9 +1,9 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
@@ -137,7 +137,7 @@ export class OAuthFlowController {
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@UseGuards(OAuthClientCredentialsGuard)
@UseGuards(ApiAuthGuard)
async refreshAccessToken(
@Param("clientId") clientId: string,
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@@ -1,94 +0,0 @@
import { AppModule } from "@/app.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team } from "@prisma/client";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard";
describe("OAuthClientCredentialsGuard", () => {
let guard: OAuthClientCredentialsGuard;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let oauthClient: PlatformOAuthClient;
let organization: Team;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule, OAuthClientModule],
}).compile();
guard = module.get<OAuthClientCredentialsGuard>(OAuthClientCredentialsGuard);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: "organization" });
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
});
it("should be defined", () => {
expect(guard).toBeDefined();
expect(oauthClient).toBeDefined();
});
it("should return true if client ID and secret are valid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false if client ID is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: "invalid id" }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
it("should return false if secret key is invalid", async () => {
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: "invalid secret" },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oauthClient.id);
await teamRepositoryFixture.delete(organization.id);
});
function createMockExecutionContext(
headers: Record<string, string>,
params: Record<string, string>
): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
headers,
params,
get: (headerName: string) => headers[headerName],
}),
}),
});
}
});
@@ -1,33 +0,0 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Request } from "express";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class OAuthClientCredentialsGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const { params } = request;
const oauthClientId = params.clientId;
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
if (!oauthClientId) {
throw new UnauthorizedException("Missing client ID");
}
if (!oauthClientSecret) {
throw new UnauthorizedException("Missing client secret");
}
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
if (!client || client.secret !== oauthClientSecret) {
throw new UnauthorizedException("Invalid client credentials");
}
return true;
}
}
@@ -6,7 +6,6 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -34,14 +33,8 @@ import { Global, Module } from "@nestjs/common";
BillingModule,
SchedulesModule_2024_04_15,
],
providers: [
OAuthClientRepository,
OAuthClientCredentialsGuard,
TokensRepository,
OAuthFlowService,
OAuthClientUsersService,
],
providers: [OAuthClientRepository, TokensRepository, OAuthFlowService, OAuthClientUsersService],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
exports: [OAuthClientRepository],
})
export class OAuthClientModule {}
@@ -1,7 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -32,6 +34,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateTeamEventTypeInput_2024_06_14,
GetTeamEventTypesQuery_2024_06_14,
SkipTakePagination,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@@ -40,13 +43,13 @@ import {
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Event Types")
export class OrganizationsEventTypesController {
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Post("/teams/:teamId/event-types")
async createTeamEventType(
@GetUser() user: UserWithProfile,
@@ -68,7 +71,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Get("/teams/:teamId/event-types/:eventTypeId")
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -86,10 +90,22 @@ export class OrganizationsEventTypesController {
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@UseGuards(IsOrgGuard, IsTeamInOrg)
@Get("/teams/:teamId/event-types")
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
async getTeamEventTypes(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
if (eventSlug) {
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
return {
status: SUCCESS_STATUS,
data: eventType ? [eventType] : [],
};
}
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
return {
@@ -99,6 +115,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@Get("/teams/event-types")
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -114,7 +132,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Patch("/teams/:teamId/event-types/:eventTypeId")
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@@ -136,7 +155,8 @@ export class OrganizationsEventTypesController {
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@Delete("/teams/:teamId/event-types/:eventTypeId")
@HttpCode(HttpStatus.OK)
async deleteTeamEventType(
@@ -252,13 +252,9 @@ describe("Organizations Event Types Endpoints", () => {
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
{
userId: teammate2.id,
mandatory: false,
priority: "low",
},
],
};
@@ -431,8 +427,6 @@ describe("Organizations Event Types Endpoints", () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teammate1.id,
mandatory: true,
priority: "medium",
},
];
@@ -603,7 +597,9 @@ describe("Organizations Event Types Endpoints", () => {
await userRepositoryFixture.deleteByEmail(teammate2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(falseTestTeam.id);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(falseTestOrg.id);
await app.close();
});
});
@@ -1,7 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
@@ -39,12 +41,13 @@ import { Membership } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Memberships")
export class OrganizationsMembershipsController {
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/")
@HttpCode(HttpStatus.OK)
async getAllMemberships(
@@ -66,6 +69,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
async createMembership(
@@ -80,6 +84,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -91,6 +96,7 @@ export class OrganizationsMembershipsController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsMembershipInOrg)
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@@ -107,6 +113,7 @@ export class OrganizationsMembershipsController {
@UseGuards(IsMembershipInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
async updateMembership(
@@ -1,7 +1,9 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
@@ -47,6 +49,7 @@ export class OrganizationsSchedulesController {
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -63,6 +66,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@@ -78,6 +82,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@@ -93,6 +98,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@@ -107,6 +113,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@@ -123,6 +130,7 @@ export class OrganizationsSchedulesController {
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
@@ -1,6 +1,8 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { 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";
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsMembershipsController {
constructor(
@@ -50,6 +52,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamMemberships(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -75,6 +78,7 @@ export class OrganizationsTeamsMembershipsController {
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
@UseGuards()
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -93,6 +97,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
@@ -113,6 +118,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
@@ -135,6 +141,7 @@ export class OrganizationsTeamsMembershipsController {
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a membership of an organization's team" })
@@ -1,8 +1,10 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
@@ -10,7 +12,6 @@ import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organiza
import {
OrgMeTeamOutputDto,
OrgMeTeamsOutputResponseDto,
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
} from "@/modules/organizations/outputs/organization-team.output";
@@ -32,6 +33,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { OrgTeamOutputDto } from "@calcom/platform-types";
import { SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client";
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@@ -47,6 +49,7 @@ export class OrganizationsTeamsController {
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
@@ -62,6 +65,7 @@ export class OrganizationsTeamsController {
@Get("/me")
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
async getMyTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination,
@@ -88,6 +92,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/:teamId")
@ApiOperation({ summary: "Get a team of the organization by ID." })
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
@@ -99,6 +104,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team of the organization by ID." })
async deleteTeam(
@@ -114,6 +120,7 @@ export class OrganizationsTeamsController {
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team of the organization by ID." })
async updateTeam(
@@ -130,6 +137,7 @@ export class OrganizationsTeamsController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Create a team for an organization." })
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -1,8 +1,10 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
@@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client";
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Organizations Users")
export class OrganizationsUsersController {
@@ -47,6 +49,7 @@ export class OrganizationsUsersController {
@Get()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
@@ -66,6 +69,7 @@ export class OrganizationsUsersController {
@Post()
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@@ -85,6 +89,7 @@ export class OrganizationsUsersController {
@Patch("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async updateOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -101,6 +106,7 @@ export class OrganizationsUsersController {
@Delete("/:userId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
async deleteOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -1,122 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUrl,
Length,
ValidateNested,
} from "class-validator";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class OrgTeamOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@IsOptional()
@Expose()
readonly parentId?: number;
@IsString()
@Length(1)
@Expose()
readonly name!: string;
@IsOptional()
@IsString()
@Expose()
readonly slug?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@Expose()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBranding?: boolean;
@IsBoolean()
@Expose()
readonly isOrganization?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBookATeamMember?: boolean = false;
@IsOptional()
@IsString()
@Expose()
readonly metadata?: string;
@IsOptional()
@IsString()
@Expose()
readonly theme?: string;
@IsOptional()
@IsString()
@Expose()
readonly brandColor?: string;
@IsOptional()
@IsString()
@Expose()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@Expose()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@Expose()
readonly weekStart?: string = "Sunday";
}
import { OrgTeamOutputDto } from "@calcom/platform-types";
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
@IsString()
@@ -16,6 +16,18 @@ export class OrganizationsEventTypesRepository {
});
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
teamId_slug: {
teamId,
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true },
});
}
async getTeamEventTypes(teamId: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
@@ -2,12 +2,13 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
HostPriority,
SchedulingType,
} from "@calcom/platform-types";
@Injectable()
@@ -30,7 +31,9 @@ export class InputOrganizationsEventTypesService {
const teamEventType = {
...eventType,
hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts),
hosts: assignAllTeamMembers
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
: this.transformInputHosts(hosts, inputEventType.schedulingType),
assignAllTeamMembers,
metadata,
};
@@ -46,6 +49,11 @@ export class InputOrganizationsEventTypesService {
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!dbEventType) {
throw new BadRequestException("Event type to update not found");
}
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
const teamEventType = {
@@ -53,8 +61,8 @@ export class InputOrganizationsEventTypesService {
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
hosts: !children
? assignAllTeamMembers
? await this.getAllTeamMembers(teamId)
: this.transformInputHosts(hosts)
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
: this.transformInputHosts(hosts, dbEventType.schedulingType)
: undefined,
assignAllTeamMembers,
children,
@@ -118,28 +126,34 @@ export class InputOrganizationsEventTypesService {
});
}
async getAllTeamMembers(teamId: number) {
async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const isFixed = schedulingType === "COLLECTIVE" ? true : false;
return membersIds.map((id) => ({
userId: id,
isFixed: false,
isFixed,
priority: 2,
}));
}
transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
transformInputHosts(
inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined,
schedulingType: SchedulingType | null
) {
if (!inputHosts) {
return undefined;
}
const defaultMandatory = false;
const defaultPriority = "medium";
const defaultIsFixed = false;
return inputHosts.map((host) => ({
userId: host.userId,
isFixed: host.mandatory || defaultMandatory,
priority: getPriorityValue(host.priority || defaultPriority),
isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed,
priority: getPriorityValue(
schedulingType === "COLLECTIVE" ? "medium" : host.priority || defaultPriority
),
}));
}
}
@@ -98,6 +98,19 @@ export class OrganizationsEventTypesService {
return this.outputService.getResponseTeamEventType(eventType);
}
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug
);
if (!eventType) {
return null;
}
return this.outputService.getResponseTeamEventType(eventType);
}
async getTeamEventTypes(teamId: number) {
const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
@@ -1,9 +1,11 @@
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host } from "@prisma/client";
import { HostPriority } from "@calcom/platform-types";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/enums";
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -47,7 +49,8 @@ type Input = Pick<
export class OutputOrganizationsEventTypesService {
constructor(
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
private readonly usersRepository: UsersRepository
) {}
async getResponseTeamEventType(databaseEventType: Input) {
@@ -60,7 +63,7 @@ export class OutputOrganizationsEventTypesService {
const hosts =
databaseEventType.schedulingType === "MANAGED"
? await this.getManagedEventTypeHosts(databaseEventType.id)
: this.transformHosts(databaseEventType.hosts);
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
return {
...rest,
@@ -74,25 +77,41 @@ export class OutputOrganizationsEventTypesService {
async getManagedEventTypeHosts(eventTypeId: number) {
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
const hostsIds: number[] = [];
const transformedHosts: TeamEventTypeResponseHost[] = [];
for (const child of children) {
if (child.userId) {
hostsIds.push(child.userId);
const user = await this.usersRepository.findById(child.userId);
transformedHosts.push({ userId: child.userId, name: user?.name || "" });
}
}
return hostsIds.map((userId) => ({ userId }));
return transformedHosts;
}
transformHosts(hosts: Host[]) {
if (!hosts) return [];
async transformHosts(
databaseHosts: Host[],
schedulingType: SchedulingType | null
): Promise<TeamEventTypeResponseHost[]> {
if (!schedulingType) return [];
return hosts.map((host) => {
return {
userId: host.userId,
mandatory: host.isFixed,
priority: getPriorityLabel(host.priority || 2),
};
});
const transformedHosts: TeamEventTypeResponseHost[] = [];
const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId));
for (const databaseHost of databaseHosts) {
const databaseUser = databaseUsers.find((u) => u.id === databaseHost.userId);
if (schedulingType === "ROUND_ROBIN") {
// note(Lauris): round robin is the only team event where mandatory (isFixed) and priority are used
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
mandatory: databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
});
} else {
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
}
}
return transformedHosts;
}
}
@@ -0,0 +1,10 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [ProfilesRepository],
exports: [ProfilesRepository],
})
export class ProfilesModule {}
@@ -0,0 +1,20 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ProfilesRepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getPlatformOwnerUserId(organizationId: number) {
const profile = await this.dbRead.prisma.profile.findFirst({
where: {
organizationId,
},
orderBy: {
createdAt: "asc",
},
});
return profile?.userId;
}
}
@@ -90,6 +90,16 @@ export class UsersRepository {
});
}
async findByIds(userIds: number[]) {
return this.dbRead.prisma.user.findMany({
where: {
id: {
in: userIds,
},
},
});
}
async findByIdWithCalendars(userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
+293 -3
View File
@@ -991,6 +991,57 @@
]
}
},
"/v2/organizations/{orgId}/teams/me": {
"get": {
"operationId": "OrganizationsTeamsController_getMyTeams",
"summary": "Get the organization's teams user is a member of",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrgMeTeamsOutputResponseDto"
}
}
}
}
},
"tags": [
"Organizations Teams"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}": {
"get": {
"operationId": "OrganizationsTeamsController_getTeam",
@@ -1160,6 +1211,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"201": {
"description": "",
@@ -1262,6 +1323,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -2301,6 +2372,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
}
}
}
},
"responses": {
"200": {
"description": "",
@@ -5100,6 +5181,29 @@
"data"
]
},
"OrgMeTeamsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTeamOutputDto"
}
}
},
"required": [
"status",
"data"
]
},
"OrgTeamOutputResponseDto": {
"type": "object",
"properties": {
@@ -5329,6 +5433,57 @@
"data"
]
},
"CreateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
},
"required": [
"name",
"timeZone",
"isDefault"
]
},
"CreateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -5377,6 +5532,52 @@
"data"
]
},
"UpdateScheduleInput_2024_06_11": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "One-on-one coaching"
},
"timeZone": {
"type": "string",
"example": "Europe/Rome"
},
"availability": {
"example": [
{
"days": [
"Monday",
"Tuesday"
],
"startTime": "09:00",
"endTime": "10:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
}
},
"isDefault": {
"type": "boolean",
"example": true
},
"overrides": {
"example": [
{
"date": "2024-05-20",
"startTime": "12:00",
"endTime": "14:00"
}
],
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
}
}
}
},
"UpdateScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
@@ -6012,9 +6213,12 @@
"freq"
]
},
"Host": {
"TeamEventTypeResponseHost": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"userId": {
"type": "number"
},
@@ -6028,6 +6232,7 @@
}
},
"required": [
"name",
"userId"
]
},
@@ -6145,7 +6350,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/TeamEventTypeResponseHost"
}
},
"assignAllTeamMembers": {
@@ -6467,6 +6672,26 @@
"userId"
]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
}
},
"required": [
"status",
"data"
]
},
"CreateAvailabilityInput_2024_04_15": {
"type": "object",
"properties": {
@@ -6764,6 +6989,66 @@
"data"
]
},
"UpdateScheduleInput_2024_04_15": {
"type": "object",
"properties": {
"timeZone": {
"type": "string"
},
"name": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"schedule": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
},
"dateOverrides": {
"example": [
[],
[
{
"start": "2022-01-01T00:00:00.000Z",
"end": "2022-01-02T00:00:00.000Z"
}
],
[],
[],
[],
[],
[]
],
"items": {
"type": "array"
},
"type": "array"
}
},
"required": [
"timeZone",
"name",
"isDefault",
"schedule"
]
},
"EventTypeModel_2024_04_15": {
"type": "object",
"properties": {
@@ -7033,6 +7318,10 @@
},
"timeZone": {
"type": "string"
},
"organizationId": {
"type": "number",
"nullable": true
}
},
"required": [
@@ -7042,7 +7331,8 @@
"timeFormat",
"defaultScheduleId",
"weekStart",
"timeZone"
"timeZone",
"organizationId"
]
},
"GetMeOutput": {
@@ -46,6 +46,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
organizationId={client.organizationId}
/>
);
})}
@@ -22,6 +22,7 @@ type OAuthClientCardProps = {
secret: string;
onDelete: (id: string) => Promise<void>;
isLoading: boolean;
organizationId: number;
};
export const OAuthClientCard = ({
@@ -38,6 +39,7 @@ export const OAuthClientCard = ({
onDelete,
isLoading,
areEmailsEnabled,
organizationId,
}: OAuthClientCardProps) => {
const router = useRouter();
@@ -113,6 +115,21 @@ export const OAuthClientCard = ({
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<div className="font-semibold">Organization Id:</div>
<div>{organizationId}</div>
<Icon
name="clipboard"
type="button"
className="h-4 w-4 cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(organizationId.toString());
showToast("Organization id copied to clipboard.", "success");
}}
/>
</div>
</div>
<div className="border-subtle flex text-sm">
<span className="font-semibold">Permissions: </span>
{permissions ? <div className="flex">{clientPermissions}</div> : <>&nbsp;Disabled</>}
+3 -3
View File
@@ -18,7 +18,7 @@ describe("moveUserToMatchingOrg", () => {
});
it("should not proceed if no matching organization is found", async () => {
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch();
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch();
await moveUserToMatchingOrg({ email });
@@ -44,7 +44,7 @@ describe("moveUserToMatchingOrg", () => {
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
@@ -65,7 +65,7 @@ describe("moveUserToMatchingOrg", () => {
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
+1 -1
View File
@@ -19,7 +19,7 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email });
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
@@ -14,20 +14,22 @@ const OrganizationRepository = organizationMock.OrganizationRepository;
export const organizationScenarios = {
OrganizationRepository: {
findUniqueByMatchingAutoAcceptEmail: {
findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fakeReturnOrganization: (org: any, forInput: any) => {
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockImplementation((arg) => {
if (forInput.email === arg.email) {
return org;
OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.mockImplementation(
(arg) => {
if (forInput.email === arg.email) {
return org;
}
const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input";
console.log(errorMsg, { arg, forInput });
throw new Error(errorMsg);
}
const errorMsg = "Mock Error-fakeReturnOrganization: Unhandled input";
console.log(errorMsg, { arg, forInput });
throw new Error(errorMsg);
});
);
},
fakeNoMatch: () => {
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.mockResolvedValue(null);
OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.mockResolvedValue(null);
},
},
} satisfies Partial<Record<keyof OrganizationModule["OrganizationRepository"], unknown>>,
@@ -8,7 +8,7 @@ vi.mock("./teamUtils", () => ({
getParsedTeam: (org: any) => org,
}));
describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
describe("Organization.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail", () => {
beforeEach(async () => {
vi.resetAllMocks();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -17,7 +17,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
});
it("should return null if no organization matches the email domain", async () => {
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
email: "test@example.com",
});
@@ -29,7 +29,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
await createReviewedOrganization({ name: "Test Org 2", orgAutoAcceptEmail: "example.com" });
await expect(
OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email: "test@example.com" })
OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email: "test@example.com" })
).rejects.toThrow("Multiple organizations found with the same auto accept email domain");
});
@@ -39,7 +39,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
orgAutoAcceptEmail: "example.com",
});
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
email: "test@example.com",
});
@@ -49,7 +49,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
it("should not confuse a team with organization", async () => {
await createTeam({ name: "Test Team", orgAutoAcceptEmail: "example.com" });
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
email: "test@example.com",
});
@@ -59,7 +59,7 @@ describe("Organization.findUniqueByMatchingAutoAcceptEmail", () => {
it("should correctly match orgAutoAcceptEmail", async () => {
await createReviewedOrganization({ name: "Test Org", orgAutoAcceptEmail: "noexample.com" });
const result = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({
const result = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({
email: "test@example.com",
});
@@ -165,11 +165,12 @@ export class OrganizationRepository {
});
}
static async findUniqueByMatchingAutoAcceptEmail({ email }: { email: string }) {
static async findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email }: { email: string }) {
const emailDomain = email.split("@").at(-1);
const orgs = await prisma.team.findMany({
where: {
isOrganization: true,
isPlatform: false,
organizationSettings: {
orgAutoAcceptEmail: emailDomain,
isOrganizationVerified: true,
@@ -22,8 +22,12 @@ import type {
} from "@calcom/platform-types";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { transformApiEventTypeForAtom } from "../event-types/atom-api-transformers/transformApiEventTypeForAtom";
import {
transformApiEventTypeForAtom,
transformApiTeamEventTypeForAtom,
} from "../event-types/atom-api-transformers/transformApiEventTypeForAtom";
import { useEventType } from "../hooks/event-types/public/useEventType";
import { useTeamEventType } from "../hooks/event-types/public/useTeamEventType";
import { useAtomsContext } from "../hooks/useAtomsContext";
import { useAvailableSlots } from "../hooks/useAvailableSlots";
import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
@@ -44,7 +48,7 @@ import { AtomsWrapper } from "../src/components/atoms-wrapper";
export type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "entity"> & {
rescheduleUid?: string;
bookingUid?: string;
username: string | string[];
username: string | string[] | undefined;
entity?: BookerProps["entity"];
// values for the booking form and booking fields
defaultFormValues?: {
@@ -68,6 +72,7 @@ export type BookerPlatformWrapperAtomProps = Omit<BookerProps, "username" | "ent
onDeleteSlotSuccess?: (data: ApiSuccessResponseWithoutData) => void;
onDeleteSlotError?: (data: ApiErrorResponse) => void;
locationUrl?: string;
teamId?: number;
};
export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => {
@@ -88,7 +93,10 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
});
const queryClient = useQueryClient();
const username = useMemo(() => {
return formatUsername(props.username);
if (props.username) {
return formatUsername(props.username);
}
return "";
}, [props.username]);
setSelectedDuration(props.duration ?? null);
@@ -98,16 +106,45 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
return getUsernameList(username ?? "").length > 1;
}, [username]);
const { isSuccess, isError, isPending, data } = useEventType(username, props.eventSlug);
const { isSuccess, isError, isPending, data } = useEventType(username, props.eventSlug, props.isTeamEvent);
const {
isSuccess: isTeamSuccess,
isError: isTeamError,
isPending: isTeamPending,
data: teamData,
} = useTeamEventType(props.teamId, props.eventSlug, props.isTeamEvent);
const event = useMemo(() => {
if (props.isTeamEvent) {
return {
isSuccess: isTeamSuccess,
isError: isTeamError,
isPending: isTeamPending,
data:
teamData && teamData.length > 0
? transformApiTeamEventTypeForAtom(teamData[0], props.entity)
: undefined,
};
}
return {
isSuccess,
isError,
isPending,
data: data && data.length > 0 ? transformApiEventTypeForAtom(data[0], props.entity) : undefined,
};
}, [isSuccess, isError, isPending, data, props.entity]);
}, [
props.isTeamEvent,
props.entity,
isSuccess,
isError,
isPending,
data,
isTeamSuccess,
isTeamError,
isTeamPending,
teamData,
]);
if (isDynamic && props.duration && event.data) {
// note(Lauris): Mandatory - In case of "dynamic" event type default event duration returned by the API is 30,
@@ -188,7 +225,7 @@ export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) =>
// Should only wait for one or the other, not both.
(Boolean(eventSlug) || Boolean(event?.data?.id) || event?.data?.id === 0),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
});
const bookerForm = useBookingForm({
@@ -9,6 +9,7 @@ import frTranslations from "@calcom/web/public/static/locales/fr/common.json";
import ptBrTranslations from "@calcom/web/public/static/locales/pt-BR/common.json";
import { AtomsContext } from "../hooks/useAtomsContext";
import { useMe } from "../hooks/useMe";
import { useOAuthClient } from "../hooks/useOAuthClient";
import { useOAuthFlow } from "../hooks/useOAuthFlow";
import { useTimezone } from "../hooks/useTimezone";
@@ -38,6 +39,7 @@ export function BaseCalProvider({
onTimezoneChange,
}: CalProviderProps) {
const [error, setError] = useState<string>("");
const { data: me } = useMe();
const { mutateAsync } = useUpdateUserTimezone();
@@ -117,6 +119,7 @@ export function BaseCalProvider({
isInit: isInit,
isValidClient: Boolean(!error && clientId && isInit),
isAuth: Boolean(isInit && !error && clientId && currentAccessToken && http.getAuthorizationHeader()),
organizationId: me?.data.organizationId || 0,
...translations,
}}>
<TooltipProvider>{children}</TooltipProvider>
@@ -134,6 +137,7 @@ export function BaseCalProvider({
isInit: false,
isRefreshing: false,
...translations,
organizationId: 0,
}}>
<>
<TooltipProvider>{children}</TooltipProvider>
@@ -7,7 +7,7 @@ import {
transformApiEventTypeBookingFields,
} from "@calcom/lib/event-types/transformers";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import type { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import {
bookerLayoutOptions,
BookerLayouts,
@@ -91,6 +91,91 @@ export function transformApiEventTypeForAtom(
};
}
export function transformApiTeamEventTypeForAtom(
eventType: TeamEventTypeOutput_2024_06_14,
entity: BookerProps["entity"] | undefined
) {
const { lengthInMinutes, locations, hosts, bookingFields, ...rest } = eventType;
const isDefault = isDefaultEvent(rest.title);
const defaultEventBookerLayouts = {
enabledLayouts: [...bookerLayoutOptions],
defaultLayout: BookerLayouts.MONTH_VIEW,
};
const firstUsersMetadata = userMetadataSchema.parse({});
const bookerLayouts = bookerLayoutsSchema.parse(
firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts
);
return {
...rest,
length: lengthInMinutes,
locations: getLocations(locations),
bookingFields: getBookingFields(bookingFields),
isDefault,
isDynamic: false,
profile: {
username: "team",
name: "team",
weekStart: "Sunday",
image: "",
brandColor: null,
darkBrandColor: null,
theme: null,
bookerLayouts,
},
entity: entity
? {
...entity,
orgSlug: entity.orgSlug || null,
teamSlug: entity.teamSlug || null,
fromRedirectOfNonOrgLink: true,
name: entity.name || null,
logoUrl: entity.logoUrl || undefined,
}
: {
fromRedirectOfNonOrgLink: true,
considerUnpublished: false,
orgSlug: null,
teamSlug: null,
name: null,
logoUrl: undefined,
},
hosts: hosts.map((host) => ({
user: {
id: host.userId,
avatarUrl: null,
name: host.name,
username: "",
metadata: {},
darkBrandColor: null,
brandColor: null,
theme: null,
weekStart: "Sunday",
},
})),
users: hosts.map((host) => ({
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(null),
profile: {
username: "",
name: host.name,
weekStart: "Sunday",
image: "",
brandColor: null,
darkBrandColor: null,
theme: null,
organization: null,
id: host.userId,
organizationId: null,
userId: host.userId,
upId: `usr-${host.userId}`,
},
})),
};
}
function isDefaultEvent(eventSlug: string) {
const foundInDefaults = defaultEvents.find((obj) => {
return obj.slug === eventSlug;
@@ -13,7 +13,7 @@ import http from "../../../lib/http";
export const QUERY_KEY = "use-event-type";
export type UsePublicEventReturnType = ReturnType<typeof useEventType>;
export const useEventType = (username: string, eventSlug: string) => {
export const useEventType = (username: string, eventSlug: string, isTeamEvent: boolean | undefined) => {
const [stateUsername, stateEventSlug] = useBookerStore(
(state) => [state.username, state.eventSlug],
shallow
@@ -29,6 +29,10 @@ export const useEventType = (username: string, eventSlug: string) => {
const event = useQuery({
queryKey: [QUERY_KEY, stateUsername ?? username, stateEventSlug ?? eventSlug],
queryFn: async () => {
if (isTeamEvent) {
return;
}
if (isDynamic) {
return http
.get<ApiResponse<EventTypeOutput_2024_06_14[]>>(
@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { shallow } from "zustand/shallow";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiSuccessResponse, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { ApiResponse } from "@calcom/platform-types";
import http from "../../../lib/http";
import { useAtomsContext } from "../../useAtomsContext";
export const QUERY_KEY = "use-team-event-type";
export const useTeamEventType = (teamId: number | undefined, eventSlug: string, isTeamEvent: boolean | undefined) => {
const { organizationId } = useAtomsContext();
const [stateEventSlug] = useBookerStore(
(state) => [state.eventSlug],
shallow
);
const requestEventSlug = stateEventSlug ?? eventSlug;
const pathname = `/organizations/${organizationId}/teams/${teamId}/event-types?eventSlug=${requestEventSlug}`;
const event = useQuery({
queryKey: [QUERY_KEY, eventSlug, organizationId, teamId],
queryFn: async () => {
if(organizationId && teamId && eventSlug && isTeamEvent) {
return http?.get<ApiResponse<TeamEventTypeOutput_2024_06_14[]>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]>).data;
}
throw new Error(res.data.error.message);
});
}
},
});
return event;
};
@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../../../lib/http";
import { useAtomsContext } from "../../useAtomsContext";
export const QUERY_KEY = "use-team-event-types";
export const useTeamEventTypes = (teamId: number) => {
const { organizationId } = useAtomsContext();
const pathname = `/organizations/${organizationId}/teams/${teamId}/event-types`;
return useQuery({
queryKey: [QUERY_KEY, organizationId, teamId],
queryFn: () => {
return http?.get<ApiResponse<TeamEventTypeOutput_2024_06_14[]>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: !!organizationId && !!teamId,
});
};
@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { ApiResponse } from "@calcom/platform-types";
import type { OrgTeamOutputDto } from "@calcom/platform-types";
import http from "../../lib/http";
import { useAtomsContext } from "../useAtomsContext";
export const QUERY_KEY = "use-teams";
export const useTeams = () => {
const { organizationId } = useAtomsContext();
const pathname = `/organizations/${organizationId}/teams/me`;
const event = useQuery({
queryKey: [QUERY_KEY, organizationId],
queryFn: async () => {
return http?.get<ApiResponse<OrgTeamOutputDto[]>>(pathname).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<OrgTeamOutputDto[]>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: !!organizationId,
});
return event;
};
@@ -26,11 +26,13 @@ export interface IAtomsContext {
locales: CalProviderLanguagesType[];
exists: (key: translationKeys | string) => boolean;
};
organizationId: number;
}
export const AtomsContext = createContext({
clientId: "",
accessToken: "",
organizationId: 0,
options: { refreshUrl: "", apiUrl: "" },
error: "",
getClient: () => {
+2
View File
@@ -6,6 +6,7 @@ export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
export { useEventTypes } from "./hooks/event-types/public/useEventTypes";
export { useTeamEventTypes } from "./hooks/event-types/public/useTeamEventTypes";
export { useEventType as useEvent } from "./hooks/event-types/public/useEventType";
export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useCancelBooking } from "./hooks/useCancelBooking";
@@ -18,3 +19,4 @@ export { BookerEmbed } from "./booker-embed";
export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials";
export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar";
export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";
export { useTeams } from "./hooks/teams/useTeams";
@@ -1,3 +1,4 @@
NEXT_PUBLIC_X_CAL_ID=""
X_CAL_SECRET_KEY=""
NEXT_PUBLIC_CALCOM_API_URL="http://localhost:5555/api/v2"
ORGANIZATION_ID=
@@ -26,6 +26,9 @@ function generateRandomEmail() {
return `${randomLocalPart}@${randomDomain}`;
}
// note(Lauris): needed because useEffect kicks in twice creating 2 parallel requests
let seeding = false;
export default function App({ Component, pageProps }: AppProps) {
const [accessToken, setAccessToken] = useState("");
const [email, setUserEmail] = useState("");
@@ -47,16 +50,21 @@ export default function App({ Component, pageProps }: AppProps) {
}, []);
useEffect(() => {
const randomEmail = generateRandomEmail();
fetch("/api/managed-user", {
method: "POST",
body: JSON.stringify({ email: randomEmail }),
}).then(async (res) => {
const data = await res.json();
setAccessToken(data.accessToken);
setUserEmail(data.email);
setUsername(data.username);
});
const randomEmailOne = generateRandomEmail();
const randomEmailTwo = generateRandomEmail();
if (!seeding) {
seeding = true;
fetch("/api/managed-user", {
method: "POST",
body: JSON.stringify({ emails: [randomEmailOne, randomEmailTwo] }),
}).then(async (res) => {
const data = await res.json();
setAccessToken(data.accessToken);
setUserEmail(data.email);
setUsername(data.username);
});
}
}, []);
useEffect(() => {
if (!!selectedUser) {
@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import prisma from "../../lib/prismaClient";
@@ -14,7 +14,9 @@ type Data = {
// example endpoint to create a managed cal.com user
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
const { email } = JSON.parse(req.body);
const { emails } = JSON.parse(req.body);
const emailOne = emails[0];
const emailTwo = emails[1];
const existingUser = await prisma.user.findFirst({ orderBy: { createdAt: "desc" } });
if (existingUser && existingUser.calcomUserId) {
@@ -25,11 +27,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
accessToken: existingUser.accessToken ?? "",
});
}
const localUser = await prisma.user.create({
const localUserOne = await prisma.user.create({
data: {
email,
email: emailOne,
},
});
const localUserTwo = await prisma.user.create({
data: {
email: emailTwo,
},
});
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
@@ -42,11 +52,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
origin: "http://localhost:4321",
},
body: JSON.stringify({
email,
email: emailOne,
name: "John Jones",
}),
}
);
const body = await response.json();
await prisma.user.update({
data: {
@@ -55,9 +66,55 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
calcomUserId: body.data?.user.id,
calcomUsername: (body.data?.user.username as string) ?? "",
},
where: { id: localUser.id },
where: { id: localUserOne.id },
});
const responseTwo = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/oauth-clients/${process.env.NEXT_PUBLIC_X_CAL_ID}/users`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
email: emailTwo,
name: "Jane Doe",
}),
}
);
const bodyTwo = await responseTwo.json();
await prisma.user.update({
data: {
refreshToken: (bodyTwo.data?.refreshToken as string) ?? "",
accessToken: (bodyTwo.data?.accessToken as string) ?? "",
calcomUserId: bodyTwo.data?.user.id,
calcomUsername: (bodyTwo.data?.user.username as string) ?? "",
},
where: { id: localUserTwo.id },
});
await createDefaultSchedule(body.data?.accessToken as string);
await createDefaultSchedule(bodyTwo.data?.accessToken as string);
// eslint-disable-next-line turbo/no-undeclared-env-vars
const organizationId = process.env.ORGANIZATION_ID;
if (!organizationId) {
throw new Error("Organization ID is not set");
}
const team = await createTeam(+organizationId, "Team Doe");
if (!team) {
throw new Error("Failed to create team. Probably your platform team does not have required plan.");
}
await createMembership(+organizationId, team.id, body.data?.user.id);
await createMembership(+organizationId, team.id, bodyTwo.data?.user.id);
await createCollectiveEventType(+organizationId, team.id, [body.data?.user.id, bodyTwo.data?.user.id]);
return res.status(200).json({
id: body?.data?.user?.id,
email: (body.data?.user.email as string) ?? "",
@@ -66,6 +123,77 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
});
}
async function createTeam(orgId: number, name: string) {
const response = await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
name,
}),
}
);
const body = await response.json();
return body.data;
}
async function createMembership(orgId: number, teamId: number, userId: number) {
await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/memberships`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
userId,
accepted: true,
}),
}
);
}
async function createCollectiveEventType(orgId: number, teamId: number, userIds: number[]) {
await fetch(
// eslint-disable-next-line turbo/no-undeclared-env-vars
`${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "",
// eslint-disable-next-line turbo/no-undeclared-env-vars
[X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "",
origin: "http://localhost:4321",
},
body: JSON.stringify({
lengthInMinutes: 60,
title: "Doe collective",
slug: "doe-collective",
schedulingType: "COLLECTIVE",
hosts: userIds.map((userId) => ({ userId })),
}),
}
);
}
async function createDefaultSchedule(accessToken: string) {
const name = "Default Schedule";
const timeZone = "Europe/London";
@@ -4,7 +4,7 @@ import { Inter } from "next/font/google";
import { useRouter } from "next/router";
import { useState } from "react";
import { Booker, useEventTypes } from "@calcom/atoms";
import { Booker, useEventTypes, useTeamEventTypes, useTeams } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
@@ -12,8 +12,11 @@ export default function Bookings(props: { calUsername: string; calEmail: string
const [bookingTitle, setBookingTitle] = useState<string | null>(null);
const [eventTypeSlug, setEventTypeSlug] = useState<string | null>(null);
const [eventTypeDuration, setEventTypeDuration] = useState<number | null>(null);
const [isTeamEvent, setIsTeamEvent] = useState<boolean>(false);
const router = useRouter();
const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypes(props.calUsername);
const { data: teams } = useTeams();
const { isLoading: isLoadingTeamEvents, data: teamEventTypes } = useTeamEventTypes(teams?.[0]?.id || 0);
const rescheduleUid = (router.query.rescheduleUid as string) ?? "";
const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? "";
@@ -28,6 +31,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
{!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && !rescheduleUid && (
<div className="flex flex-col gap-4">
<h1>User event types</h1>
{eventTypes?.map(
(event: { id: number; slug: string; title: string; lengthInMinutes: number }) => {
const formatEventSlug = event.slug
@@ -40,6 +44,36 @@ export default function Bookings(props: { calUsername: string; calEmail: string
onClick={() => {
setEventTypeSlug(event.slug);
setEventTypeDuration(event.lengthInMinutes);
setIsTeamEvent(false);
}}
className="mx-10 w-[80vw] cursor-pointer rounded-md border-[0.8px] border-black px-10 py-4"
key={event.id}>
<h1 className="text-lg font-semibold">{formatEventSlug}</h1>
<p>{`/${event.slug}`}</p>
<span className="border-none bg-gray-800 px-2 text-white">{event?.lengthInMinutes}</span>
</div>
);
}
)}
</div>
)}
{!isLoadingTeamEvents && !eventTypeSlug && Boolean(teamEventTypes?.length) && !rescheduleUid && (
<div className="flex flex-col gap-4">
<h1>Team event types</h1>
{teamEventTypes?.map(
(event: { id: number; slug: string; title: string; lengthInMinutes: number }) => {
const formatEventSlug = event.slug
.split("-")
.map((item) => `${item[0].toLocaleUpperCase()}${item.slice(1)}`)
.join(" ");
return (
<div
onClick={() => {
setEventTypeSlug(event.slug);
setEventTypeDuration(event.lengthInMinutes);
setIsTeamEvent(true);
}}
className="mx-10 w-[80vw] cursor-pointer rounded-md border-[0.8px] border-black px-10 py-4"
key={event.id}>
@@ -54,28 +88,33 @@ export default function Bookings(props: { calUsername: string; calEmail: string
)}
{!bookingTitle && eventTypeSlug && !rescheduleUid && (
<Booker
eventSlug={eventTypeSlug}
username={props.calUsername ?? ""}
onCreateBookingSuccess={(data) => {
setBookingTitle(data.data.title ?? "");
router.push(`/${data.data.uid}`);
}}
duration={eventTypeDuration}
customClassNames={{
bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border",
datePickerCustomClassNames: {
datePickerDatesActive: "!bg-[#D7CEF5]",
},
eventMetaCustomClassNames: {
eventMetaTitle: "text-[#7151DC]",
},
availableTimeSlotsCustomClassNames: {
availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]",
availableTimes: "!bg-[#D7CEF5]",
},
}}
/>
<>
<h1>{eventTypeSlug}</h1>
<Booker
eventSlug={eventTypeSlug}
username={props.calUsername ?? ""}
onCreateBookingSuccess={(data) => {
setBookingTitle(data.data.title ?? "");
router.push(`/${data.data.uid}`);
}}
teamId={teams?.[0]?.id || 0}
isTeamEvent={isTeamEvent}
duration={eventTypeDuration}
customClassNames={{
bookerContainer: "!bg-[#F5F2FE] [&_button:!rounded-full] border-subtle border",
datePickerCustomClassNames: {
datePickerDatesActive: "!bg-[#D7CEF5]",
},
eventMetaCustomClassNames: {
eventMetaTitle: "text-[#7151DC]",
},
availableTimeSlotsCustomClassNames: {
availableTimeSlotsHeaderContainer: "!bg-[#F5F2FE]",
availableTimes: "!bg-[#D7CEF5]",
},
}}
/>
</>
)}
{!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && (
<Booker
@@ -15,6 +15,12 @@ export class GetEventTypesQuery_2024_06_14 {
usernames?: string[];
}
export class GetTeamEventTypesQuery_2024_06_14 {
@IsString()
@IsOptional()
eventSlug?: string;
}
function TransformUsernames() {
return Transform(({ value }) => {
if (typeof value === "string") {
@@ -12,7 +12,7 @@ import {
} from "class-validator";
import type { Location_2024_06_14, BookingField_2024_06_14 } from "../inputs";
import { Host } from "../inputs";
import { Host as TeamEventTypeHostInput } from "../inputs";
import { RecurringEvent_2024_06_14 } from "../inputs";
import { ValidateBookingFields_2024_06_14 } from "../inputs/booking-fields.input";
import { ValidateLocations_2024_06_14 } from "../inputs/locations.input";
@@ -139,6 +139,11 @@ export class EventTypeOutput_2024_06_14 {
scheduleId!: number | null;
}
export class TeamEventTypeResponseHost extends TeamEventTypeHostInput {
@IsString()
name!: string;
}
export class TeamEventTypeOutput_2024_06_14 {
@IsInt()
@DocsProperty({ example: 1 })
@@ -235,9 +240,9 @@ export class TeamEventTypeOutput_2024_06_14 {
parentEventTypeId?: number | null;
@ValidateNested({ each: true })
@Type(() => Host)
@Type(() => TeamEventTypeResponseHost)
@IsArray()
hosts!: Host[];
hosts!: TeamEventTypeResponseHost[];
@IsBoolean()
@IsOptional()
+1
View File
@@ -7,3 +7,4 @@ export * from "./bookings";
export * from "./billing";
export * from "./schedules";
export * from "./event-types";
export * from "./organizations";
+1
View File
@@ -46,6 +46,7 @@ export const userSchemaResponse = z.object({
weekStart: z.string(),
timeZone: z.string().default("Europe/London"),
username: z.string(),
organizationId: z.number().nullable(),
});
export type UserResponse = z.infer<typeof userSchemaResponse>;
@@ -0,0 +1 @@
export * from "./teams";
@@ -0,0 +1 @@
export * from "./outputs";
@@ -0,0 +1 @@
export * from "./team.output";
@@ -0,0 +1,107 @@
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class OrgTeamOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@IsOptional()
@Expose()
readonly parentId?: number;
@IsString()
@Length(1)
@Expose()
readonly name!: string;
@IsOptional()
@IsString()
@Expose()
readonly slug?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@Expose()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBranding?: boolean;
@IsBoolean()
@Expose()
readonly isOrganization?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBookATeamMember?: boolean = false;
@IsOptional()
@IsString()
@Expose()
readonly metadata?: string;
@IsOptional()
@IsString()
@Expose()
readonly theme?: string;
@IsOptional()
@IsString()
@Expose()
readonly brandColor?: string;
@IsOptional()
@IsString()
@Expose()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@Expose()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@Expose()
readonly weekStart?: string = "Sunday";
}