first commit
This commit is contained in:
14
calcom/apps/api/v2/src/app.controller.ts
Normal file
14
calcom/apps/api/v2/src/app.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
@Controller()
|
||||
@DocsTags("Health - development only")
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
export class AppController {
|
||||
@Get("health")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
getHealth(): "OK" {
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
26
calcom/apps/api/v2/src/app.e2e-spec.ts
Normal file
26
calcom/apps/api/v2/src/app.e2e-spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AppModule } from "@/app.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer()).get("/health").expect("OK");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
91
calcom/apps/api/v2/src/app.module.ts
Normal file
91
calcom/apps/api/v2/src/app.module.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import appConfig from "@/config/app";
|
||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||
import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
|
||||
import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { EndpointsModule } from "@/modules/endpoints.module";
|
||||
import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: true,
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
|
||||
RedisModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
useFactory: async (redisService: RedisService) => ({
|
||||
redis: {
|
||||
host: redisService.redis.options.host,
|
||||
port: redisService.redis.options.port,
|
||||
},
|
||||
}),
|
||||
inject: [RedisService],
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
name: "short",
|
||||
ttl: seconds(10),
|
||||
limit: 3,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
ttl: seconds(30),
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
EndpointsModule,
|
||||
AuthModule,
|
||||
JwtModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer
|
||||
.apply(RawBodyMiddleware)
|
||||
.forRoutes({
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(AppLoggerMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RewriterMiddleware)
|
||||
.forRoutes("/");
|
||||
}
|
||||
}
|
||||
85
calcom/apps/api/v2/src/app.ts
Normal file
85
calcom/apps/api/v2/src/app.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import "./instrument";
|
||||
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import type { ValidationError } from "@nestjs/common";
|
||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import * as cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
import helmet from "helmet";
|
||||
|
||||
import {
|
||||
API_VERSIONS,
|
||||
VERSION_2024_04_15,
|
||||
API_VERSIONS_ENUM,
|
||||
CAL_API_VERSION_HEADER,
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
app.enableVersioning({
|
||||
type: VersioningType.CUSTOM,
|
||||
extractor: (request: unknown) => {
|
||||
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||
return headerVersion;
|
||||
}
|
||||
return VERSION_2024_04_15;
|
||||
},
|
||||
defaultVersion: VERSION_2024_04_15,
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
CAL_API_VERSION_HEADER,
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Origin",
|
||||
],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
validationError: {
|
||||
target: true,
|
||||
value: true,
|
||||
},
|
||||
exceptionFactory(errors: ValidationError[]) {
|
||||
return new BadRequestException({ errors });
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Exception filters, new filters go at the bottom, keep the order
|
||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
|
||||
}
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new ZodExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new TRPCExceptionFilter());
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
return app;
|
||||
};
|
||||
41
calcom/apps/api/v2/src/config/app.ts
Normal file
41
calcom/apps/api/v2/src/config/app.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getEnv } from "@/env";
|
||||
|
||||
import type { AppConfig } from "./type";
|
||||
|
||||
const loadConfig = (): AppConfig => {
|
||||
return {
|
||||
env: {
|
||||
type: getEnv("NODE_ENV", "development"),
|
||||
},
|
||||
api: {
|
||||
port: Number(getEnv("API_PORT", "5555")),
|
||||
path: getEnv("API_URL", "http://localhost"),
|
||||
url: `${getEnv("API_URL", "http://localhost")}${
|
||||
process.env.API_PORT && getEnv("NODE_ENV", "development") === "development"
|
||||
? `:${Number(getEnv("API_PORT", "5555"))}`
|
||||
: ""
|
||||
}/v2`,
|
||||
keyPrefix: getEnv("API_KEY_PREFIX", "cal_"),
|
||||
licenseKey: getEnv("CALCOM_LICENSE_KEY", ""),
|
||||
licenseKeyUrl: getEnv("GET_LICENSE_KEY_URL", "https://console.cal.com/api/license"),
|
||||
},
|
||||
db: {
|
||||
readUrl: getEnv("DATABASE_READ_URL"),
|
||||
writeUrl: getEnv("DATABASE_WRITE_URL"),
|
||||
redisUrl: getEnv("REDIS_URL"),
|
||||
},
|
||||
next: {
|
||||
authSecret: getEnv("NEXTAUTH_SECRET"),
|
||||
},
|
||||
stripe: {
|
||||
apiKey: getEnv("STRIPE_API_KEY"),
|
||||
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
|
||||
},
|
||||
app: {
|
||||
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
|
||||
},
|
||||
e2e: getEnv("IS_E2E", false),
|
||||
};
|
||||
};
|
||||
|
||||
export default loadConfig;
|
||||
29
calcom/apps/api/v2/src/config/type.ts
Normal file
29
calcom/apps/api/v2/src/config/type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type AppConfig = {
|
||||
env: {
|
||||
type: "production" | "development";
|
||||
};
|
||||
api: {
|
||||
port: number;
|
||||
path: string;
|
||||
url: string;
|
||||
keyPrefix: string;
|
||||
licenseKey: string;
|
||||
licenseKeyUrl: string;
|
||||
};
|
||||
db: {
|
||||
readUrl: string;
|
||||
writeUrl: string;
|
||||
redisUrl: string;
|
||||
};
|
||||
next: {
|
||||
authSecret: string;
|
||||
};
|
||||
stripe: {
|
||||
apiKey: string;
|
||||
webhookSecret: string;
|
||||
};
|
||||
app: {
|
||||
baseUrl: string;
|
||||
};
|
||||
e2e: boolean;
|
||||
};
|
||||
42
calcom/apps/api/v2/src/ee/LICENSE
Normal file
42
calcom/apps/api/v2/src/ee/LICENSE
Normal file
@@ -0,0 +1,42 @@
|
||||
The Cal.com Commercial License (the “Commercial License”)
|
||||
Copyright (c) 2020-present Cal.com, Inc
|
||||
|
||||
With regard to the Cal.com Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Cal.com Subscription Terms available
|
||||
at https://cal.com/terms, or other agreements governing
|
||||
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
|
||||
and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription")
|
||||
for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence,
|
||||
you are free to modify this Software and publish patches to the Software. You agree
|
||||
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Commercial Subscription for the correct number of hosts.
|
||||
Notwithstanding the foregoing, you may copy and modify the Software for development
|
||||
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
|
||||
its licensors (as applicable) retain all right, title and interest in and to all such
|
||||
modifications. You are not granted any other rights beyond what is expressly stated herein.
|
||||
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This Commercial License applies only to the part of this Software that is not distributed under
|
||||
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
|
||||
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
|
||||
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
|
||||
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Cal.com Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
18
calcom/apps/api/v2/src/ee/README.md
Normal file
18
calcom/apps/api/v2/src/ee/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- PROJECT LOGO -->
|
||||
<div align="center">
|
||||
<a href="https://cal.com/enterprise">
|
||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
||||
</a>
|
||||
|
||||
<a href="https://console.cal.com/">Get a License Key</a>
|
||||
</div>
|
||||
|
||||
# Enterprise Edition of API
|
||||
|
||||
Welcome to the Enterprise Edition ("/ee") of the Cal.com API.
|
||||
|
||||
Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All "Multiplayer APIs" are under a commercial license.
|
||||
|
||||
The [/ee](https://github.com/calcom/cal.com/tree/main/apps/api/v2/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://cal.com/pricing) plan and enterprise-grade features for [Enterprise](https://cal.com/enterprise) such as SSO, SAML, OIDC, SCIM, SIEM and much more or [Platform](https://cal.com/platform) plan to build a marketplace.
|
||||
|
||||
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calcom/cal.com)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://console.cal.com/) first❗_
|
||||
16
calcom/apps/api/v2/src/ee/bookings/bookings.module.ts
Normal file
16
calcom/apps/api/v2/src/ee/bookings/bookings.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
|
||||
controllers: [BookingsController],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
describe("User Authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
const userEmail = "bookings-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
|
||||
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{ title: "peer coding", slug: "peer-coding", length: 60 },
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a booking", async () => {
|
||||
const bookingStart = "2040-05-21T09:30:00.000Z";
|
||||
const bookingEnd = "2040-05-21T10:30:00.000Z";
|
||||
const bookingEventTypeId = eventTypeId;
|
||||
const bookingTimeZone = "Europe/London";
|
||||
const bookingLanguage = "en";
|
||||
const bookingHashedLink = "";
|
||||
const bookingMetadata = {};
|
||||
const bookingResponses = {
|
||||
name: "tester",
|
||||
email: "tester@example.com",
|
||||
location: {
|
||||
value: "link",
|
||||
optionValue: "",
|
||||
},
|
||||
notes: "test",
|
||||
guests: [],
|
||||
};
|
||||
|
||||
const body = {
|
||||
start: bookingStart,
|
||||
end: bookingEnd,
|
||||
eventTypeId: bookingEventTypeId,
|
||||
timeZone: bookingTimeZone,
|
||||
language: bookingLanguage,
|
||||
metadata: bookingMetadata,
|
||||
hashedLink: bookingHashedLink,
|
||||
responses: bookingResponses,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||
response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
|
||||
expect(responseBody.data.id).toBeDefined();
|
||||
expect(responseBody.data.uid).toBeDefined();
|
||||
expect(responseBody.data.startTime).toEqual(bookingStart);
|
||||
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
|
||||
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
|
||||
expect(responseBody.data.metadata).toEqual(bookingMetadata);
|
||||
|
||||
createdBooking = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?filters[status]=upcoming")
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingsOutput = response.body;
|
||||
const fetchedBooking = responseBody.data.bookings[0];
|
||||
|
||||
expect(responseBody.data.bookings.length).toEqual(1);
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(fetchedBooking).toBeDefined();
|
||||
|
||||
expect(fetchedBooking.id).toEqual(createdBooking.id);
|
||||
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
|
||||
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
|
||||
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
|
||||
expect(fetchedBooking.user?.email).toEqual(userEmail);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get booking", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
||||
.then((response) => {
|
||||
const responseBody: GetBookingOutput = response.body;
|
||||
const bookingInfo = responseBody.data;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(bookingInfo?.id).toBeDefined();
|
||||
expect(bookingInfo?.uid).toBeDefined();
|
||||
expect(bookingInfo?.id).toEqual(createdBooking.id);
|
||||
expect(bookingInfo?.uid).toEqual(createdBooking.uid);
|
||||
expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId);
|
||||
expect(bookingInfo?.startTime).toEqual(createdBooking.startTime);
|
||||
});
|
||||
});
|
||||
|
||||
// note(Lauris) : found this test broken here - first thing to fix is that recurring endpoint accepts an array not 1 object.
|
||||
// it("should create a recurring booking", async () => {
|
||||
// const bookingStart = "2040-05-25T09:30:00.000Z";
|
||||
// const bookingEnd = "2040-05-25T10:30:00.000Z";
|
||||
// const bookingEventTypeId = 7;
|
||||
// const bookingTimeZone = "Europe/London";
|
||||
// const bookingLanguage = "en";
|
||||
// const bookingHashedLink = "";
|
||||
// const bookingRecurringCount = 5;
|
||||
// const currentBookingRecurringIndex = 0;
|
||||
|
||||
// const body = {
|
||||
// start: bookingStart,
|
||||
// end: bookingEnd,
|
||||
// eventTypeId: bookingEventTypeId,
|
||||
// timeZone: bookingTimeZone,
|
||||
// language: bookingLanguage,
|
||||
// metadata: {},
|
||||
// hashedLink: bookingHashedLink,
|
||||
// recurringCount: bookingRecurringCount,
|
||||
// currentRecurringIndex: currentBookingRecurringIndex,
|
||||
// };
|
||||
|
||||
// return request(app.getHttpServer())
|
||||
// .post("/v2/bookings/recurring")
|
||||
// .send(body)
|
||||
// .expect(201)
|
||||
// .then((response) => {
|
||||
// const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
|
||||
// response.body;
|
||||
|
||||
// expect(responseBody.status).toEqual("recurring");
|
||||
// });
|
||||
// });
|
||||
|
||||
// note(Lauris) : found this test broken here - first thing to fix is that the eventTypeId must be team event type, because
|
||||
// instant bookings only work for teams.
|
||||
// it("should create an instant booking", async () => {
|
||||
// const bookingStart = "2040-05-25T09:30:00.000Z";
|
||||
// const bookingEnd = "2040-25T10:30:00.000Z";
|
||||
// const bookingEventTypeId = 7;
|
||||
// const bookingTimeZone = "Europe/London";
|
||||
// const bookingLanguage = "en";
|
||||
// const bookingHashedLink = "";
|
||||
|
||||
// const body = {
|
||||
// start: bookingStart,
|
||||
// end: bookingEnd,
|
||||
// eventTypeId: bookingEventTypeId,
|
||||
// timeZone: bookingTimeZone,
|
||||
// language: bookingLanguage,
|
||||
// metadata: {},
|
||||
// hashedLink: bookingHashedLink,
|
||||
// };
|
||||
|
||||
// return request(app.getHttpServer())
|
||||
// .post("/v2/bookings/instant")
|
||||
// .send(body)
|
||||
// .expect(201)
|
||||
// .then((response) => {
|
||||
// const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
|
||||
|
||||
// expect(responseBody.status).toEqual("instant");
|
||||
// });
|
||||
// });
|
||||
|
||||
// cancelling a booking hangs the test for some reason
|
||||
it.skip("should cancel a booking", async () => {
|
||||
const bookingId = createdBooking.id;
|
||||
|
||||
const body = {
|
||||
allRemainingBookings: false,
|
||||
cancellationReason: "Was fighting some unforseen rescheduling demons",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingId}/cancel`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> =
|
||||
response.body;
|
||||
|
||||
expect(bookingId).toBeDefined();
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Logger,
|
||||
Req,
|
||||
InternalServerErrorException,
|
||||
Body,
|
||||
Headers,
|
||||
HttpException,
|
||||
Param,
|
||||
Get,
|
||||
Query,
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||
import {
|
||||
handleNewBooking,
|
||||
BookingResponse,
|
||||
HttpError,
|
||||
handleNewRecurringBooking,
|
||||
handleInstantMeeting,
|
||||
handleMarkNoShow,
|
||||
getAllUserBookings,
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type BookingRequest = Request & {
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
type OAuthRequestParams = {
|
||||
platformClientId: string;
|
||||
platformRescheduleUrl: string;
|
||||
platformCancelUrl: string;
|
||||
platformBookingUrl: string;
|
||||
platformBookingLocation?: string;
|
||||
arePlatformEmailsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PLATFORM_PARAMS = {
|
||||
platformClientId: "",
|
||||
platformCancelUrl: "",
|
||||
platformRescheduleUrl: "",
|
||||
platformBookingUrl: "",
|
||||
arePlatformEmailsEnabled: false,
|
||||
platformBookingLocation: undefined,
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings")
|
||||
export class BookingsController {
|
||||
private readonly logger = new Logger("BookingsController");
|
||||
|
||||
constructor(
|
||||
private readonly oAuthFlowService: OAuthFlowService,
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly billingService: BillingService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
|
||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||
@ApiQuery({ name: "cursor", type: "number", required: false })
|
||||
async getBookings(
|
||||
@GetUser() user: User,
|
||||
@Query() queryParams: GetBookingsInput
|
||||
): Promise<GetBookingsOutput> {
|
||||
const { filters, cursor, limit } = queryParams;
|
||||
const bookings = await getAllUserBookings({
|
||||
bookingListingByStatus: filters.status,
|
||||
skip: cursor ?? 0,
|
||||
take: limit ?? 10,
|
||||
filters,
|
||||
ctx: {
|
||||
user: { email: user.email, id: user.id },
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid")
|
||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput> {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
|
||||
if (!bookingInfo) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookingInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:bookingUid/reschedule")
|
||||
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
|
||||
const booking = await getBookingForReschedule(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async createBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() body: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||
);
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
fromReschedule: booking.fromReschedule,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err);
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create booking.");
|
||||
}
|
||||
|
||||
@Post("/:bookingId/cancel")
|
||||
async cancelBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Param("bookingId") bookingId: string,
|
||||
@Body() _: CancelBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
if (bookingId) {
|
||||
try {
|
||||
req.body.id = parseInt(bookingId);
|
||||
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
bookingId: res.bookingId,
|
||||
bookingUid: res.bookingUid,
|
||||
onlyRemovedAttendee: res.onlyRemovedAttendee,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err);
|
||||
}
|
||||
} else {
|
||||
throw new NotFoundException("Booking ID is required.");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not cancel booking.");
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/mark-no-show")
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async markNoShow(
|
||||
@Body() body: MarkNoShowInput,
|
||||
@Param("bookingUid") bookingUid: string
|
||||
): Promise<MarkNoShowOutput> {
|
||||
try {
|
||||
const markNoShowResponse = await handleMarkNoShow({
|
||||
bookingUid: bookingUid,
|
||||
attendees: body.attendees,
|
||||
noShowHost: body.noShowHost,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err, "no-show");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not mark no show.");
|
||||
}
|
||||
|
||||
@Post("/recurring")
|
||||
async createRecurringBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateRecurringBookingInput[],
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<BookingResponse[]>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
try {
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
startTime: booking.startTime,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: createdBookings,
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err, "recurring");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create recurring booking.");
|
||||
}
|
||||
|
||||
@Post("/instant")
|
||||
async createInstantBooking(
|
||||
@Req() req: BookingRequest,
|
||||
@Body() _: CreateBookingInput,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
try {
|
||||
const instantMeeting = await handleInstantMeeting(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||
);
|
||||
|
||||
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||
const now = new Date();
|
||||
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
|
||||
now.setSeconds(now.getSeconds() + 10);
|
||||
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
|
||||
uid: instantMeeting.bookingUid,
|
||||
startTime: now,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: instantMeeting,
|
||||
};
|
||||
} catch (err) {
|
||||
this.handleBookingErrors(err, "instant");
|
||||
}
|
||||
throw new InternalServerErrorException("Could not create instant booking.");
|
||||
}
|
||||
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
try {
|
||||
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (accessToken) {
|
||||
return this.oAuthFlowService.getOwnerId(accessToken);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
|
||||
const res = DEFAULT_PLATFORM_PARAMS;
|
||||
try {
|
||||
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
|
||||
// fetch oAuthClient from db and use data stored in db to set these values
|
||||
if (client) {
|
||||
res.platformClientId = clientId;
|
||||
res.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
|
||||
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
|
||||
res.platformBookingUrl = client.bookingRedirectUri ?? "";
|
||||
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
private async createNextApiBookingRequest(
|
||||
req: BookingRequest,
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
||||
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
err: Error | HttpError | unknown,
|
||||
type?: "recurring" | `instant` | "no-show"
|
||||
): void {
|
||||
const errMsg =
|
||||
type === "no-show"
|
||||
? `Error while marking no-show.`
|
||||
: `Error while creating ${type ? type + " " : ""}booking.`;
|
||||
if (err instanceof HttpError) {
|
||||
const httpError = err as HttpError;
|
||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const error = err as Error;
|
||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(errMsg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsTimeZone,
|
||||
IsNumber,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsEmail,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
class Location {
|
||||
@IsString()
|
||||
optionValue!: string;
|
||||
|
||||
@IsString()
|
||||
value!: string;
|
||||
}
|
||||
|
||||
class Response {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
guests!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => Location)
|
||||
location?: Location;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
end?: string;
|
||||
|
||||
@IsString()
|
||||
start!: string;
|
||||
|
||||
@IsNumber()
|
||||
eventTypeId!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
eventTypeSlug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
rescheduleUid?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
recurringEventId?: string;
|
||||
|
||||
@IsTimeZone()
|
||||
timeZone!: string;
|
||||
|
||||
@Transform(({ value }: { value: string | string[] }) => {
|
||||
return typeof value === "string" ? [value] : value;
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
user?: string[];
|
||||
|
||||
@IsString()
|
||||
language!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bookingUid?: string;
|
||||
|
||||
@IsObject()
|
||||
metadata!: Record<string, string>;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
hasHashedBookingLink?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
hashedLink!: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
seatReferenceUid?: string;
|
||||
|
||||
@Type(() => Response)
|
||||
responses!: Response;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
orgSlug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locationUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noEmail?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
recurringCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
appsStatus?: AppsStatus[] | undefined;
|
||||
|
||||
@IsOptional()
|
||||
allRecurringDates?: Record<string, string>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
currentRecurringIndex?: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
|
||||
|
||||
class Attendee {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsBoolean()
|
||||
noShow!: boolean;
|
||||
}
|
||||
|
||||
export class MarkNoShowInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noShowHost?: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Attendee)
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
attendees?: Attendee[];
|
||||
}
|
||||
173
calcom/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts
Normal file
173
calcom/apps/api/v2/src/ee/bookings/outputs/get-booking.output.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsUrl,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
} from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Metadata {
|
||||
@IsUrl()
|
||||
videoCallUrl!: string;
|
||||
}
|
||||
|
||||
class Location {
|
||||
@IsString()
|
||||
optionValue!: string;
|
||||
|
||||
@IsString()
|
||||
value!: string;
|
||||
}
|
||||
|
||||
class Response {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
notes!: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
guests!: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Location)
|
||||
location!: Location;
|
||||
}
|
||||
|
||||
class User {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
}
|
||||
|
||||
class Attendee {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
}
|
||||
|
||||
class EventType {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName!: string | null;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timeZone!: string | null;
|
||||
}
|
||||
|
||||
class GetBookingData {
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
uid!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string | null;
|
||||
|
||||
@IsObject()
|
||||
customInputs!: any;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
smsReminderNumber!: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recurringEventId!: string | null;
|
||||
|
||||
@IsDateString()
|
||||
startTime!: Date;
|
||||
|
||||
@IsDateString()
|
||||
endTime!: Date;
|
||||
|
||||
@IsUrl()
|
||||
location!: string | null;
|
||||
|
||||
@IsString()
|
||||
status!: string;
|
||||
|
||||
metadata!: Metadata | any;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cancellationReason!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Response)
|
||||
responses!: Response | any;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rejectionReason!: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsEmail()
|
||||
userPrimaryEmail!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => User)
|
||||
user!: User | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Attendee)
|
||||
@IsArray()
|
||||
attendees!: Attendee[];
|
||||
|
||||
@IsInt()
|
||||
eventTypeId!: number | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EventType)
|
||||
eventType!: EventType | null;
|
||||
}
|
||||
|
||||
export class GetBookingOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetBookingData,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingData)
|
||||
data!: GetBookingData;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
IsUrl,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
} from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
enum Status {
|
||||
CANCELLED = "CANCELLED",
|
||||
REJECTED = "REJECTED",
|
||||
ACCEPTED = "ACCEPTED",
|
||||
PENDING = "PENDING",
|
||||
AWAITING_HOST = "AWAITING_HOST",
|
||||
}
|
||||
|
||||
class Attendee {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
|
||||
@IsString()
|
||||
locale!: string | null;
|
||||
|
||||
@IsInt()
|
||||
bookingId!: number | null;
|
||||
}
|
||||
|
||||
class EventType {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string | null;
|
||||
|
||||
@IsInt()
|
||||
price!: number;
|
||||
|
||||
@IsOptional()
|
||||
recurringEvent?: any;
|
||||
|
||||
@IsString()
|
||||
currency!: string;
|
||||
|
||||
@IsObject()
|
||||
metadata!: any;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
seatsShowAttendees?: boolean | undefined | null;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
seatsShowAvailabilityCount?: boolean | undefined | null;
|
||||
|
||||
@IsOptional()
|
||||
team?: any | null;
|
||||
}
|
||||
|
||||
class Reference {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
uid!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
meetingId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
thirdPartyRecurringEventId?: string | null;
|
||||
|
||||
@IsString()
|
||||
meetingPassword!: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
meetingUrl?: string | null;
|
||||
|
||||
@IsInt()
|
||||
bookingId!: number | null;
|
||||
|
||||
@IsEmail()
|
||||
externalCalendarId!: string | null;
|
||||
|
||||
@IsOptional()
|
||||
deleted?: any;
|
||||
|
||||
@IsInt()
|
||||
credentialId!: number | null;
|
||||
}
|
||||
|
||||
class User {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
|
||||
class GetBookingsDataEntry {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
userPrimaryEmail?: string | null;
|
||||
|
||||
@IsString()
|
||||
description!: string | null;
|
||||
|
||||
@IsObject()
|
||||
customInputs!: object | any;
|
||||
|
||||
@IsDateString()
|
||||
startTime!: string;
|
||||
|
||||
@IsDateString()
|
||||
endTime!: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Attendee)
|
||||
@IsArray()
|
||||
attendees!: Attendee[];
|
||||
|
||||
metadata!: any;
|
||||
|
||||
@IsString()
|
||||
uid!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recurringEventId!: string | null;
|
||||
|
||||
@IsUrl()
|
||||
location!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EventType)
|
||||
eventType!: EventType;
|
||||
|
||||
@IsEnum(Status)
|
||||
status!: "CANCELLED" | "REJECTED" | "ACCEPTED" | "PENDING" | "AWAITING_HOST";
|
||||
|
||||
@IsBoolean()
|
||||
paid!: boolean;
|
||||
|
||||
@IsArray()
|
||||
payment!: any[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Reference)
|
||||
@IsArray()
|
||||
references!: Reference[];
|
||||
|
||||
@IsBoolean()
|
||||
isRecorded!: boolean;
|
||||
|
||||
@IsArray()
|
||||
seatsReferences!: any[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => User)
|
||||
user!: User | null;
|
||||
|
||||
@IsOptional()
|
||||
rescheduled?: any;
|
||||
}
|
||||
|
||||
class GetBookingsData {
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingsDataEntry)
|
||||
@IsArray()
|
||||
bookings!: GetBookingsDataEntry[];
|
||||
|
||||
@IsArray()
|
||||
recurringInfo!: any[];
|
||||
|
||||
@IsInt()
|
||||
nextCursor!: number | null;
|
||||
}
|
||||
|
||||
export class GetBookingsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: GetBookingsData,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => GetBookingsData)
|
||||
data!: GetBookingsData;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Attendee {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsBoolean()
|
||||
noShow!: boolean;
|
||||
}
|
||||
|
||||
class HandleMarkNoShowData {
|
||||
@IsString()
|
||||
message!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
noShowHost?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
messageKey?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Attendee)
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
attendees?: Attendee[];
|
||||
}
|
||||
|
||||
export class MarkNoShowOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: HandleMarkNoShowData,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => HandleMarkNoShowData)
|
||||
data!: HandleMarkNoShowData;
|
||||
}
|
||||
17
calcom/apps/api/v2/src/ee/calendars/calendars.interface.ts
Normal file
17
calcom/apps/api/v2/src/ee/calendars/calendars.interface.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Request } from "express";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
export interface CalendarApp {
|
||||
save(state: string, code: string, origin: string): Promise<{ url: string }>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface CredentialSyncCalendarApp {
|
||||
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
|
||||
check(userId: number): Promise<ApiResponse>;
|
||||
}
|
||||
|
||||
export interface OAuthCalendarApp extends CalendarApp {
|
||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||
}
|
||||
30
calcom/apps/api/v2/src/ee/calendars/calendars.module.ts
Normal file
30
calcom/apps/api/v2/src/ee/calendars/calendars.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
AppleCalendarService,
|
||||
SelectedCalendarsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [CalendarsController],
|
||||
exports: [CalendarsService],
|
||||
})
|
||||
export class CalendarsModule {}
|
||||
51
calcom/apps/api/v2/src/ee/calendars/calendars.repository.ts
Normal file
51
calcom/apps/api/v2/src/ee/calendars/calendars.repository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
id: true,
|
||||
appId: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
key: true,
|
||||
invalid: true,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getCalendarCredentials(credentialId: number, userId: number) {
|
||||
return await this.dbRead.prisma.credential.findFirst({
|
||||
where: {
|
||||
id: credentialId,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
...credentialForCalendarRepositorySelect,
|
||||
app: {
|
||||
select: {
|
||||
slug: true,
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCredentials(credentialId: number) {
|
||||
return await this.dbWrite.prisma.credential.delete({
|
||||
where: {
|
||||
id: credentialId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
GOOGLE_CALENDAR_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
describe("Platform Calendars Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||
let user: User;
|
||||
let office365Credentials: Credential;
|
||||
let googleCalendarCredentials: Credential;
|
||||
let accessTokenSecret: string;
|
||||
let refreshTokenSecret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
|
||||
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
jest
|
||||
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: [CLIENT_REDIRECT_URI],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(accessTokenSecret).toBeDefined();
|
||||
expect(refreshTokenSecret).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
// TODO: Uncomment this once CI is ready to run proper Office365 tests
|
||||
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
// .set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
// .set("Origin", CLIENT_REDIRECT_URI)
|
||||
// .expect(200);
|
||||
// const data = response.body.data;
|
||||
// expect(data.authUrl).toBeDefined();
|
||||
// });
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/random-calendar/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
|
||||
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
|
||||
office365Credentials = await credentialsRepositoryFixture.create(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
OFFICE_365_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
|
||||
googleCalendarCredentials = await credentialsRepositoryFixture.create(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
{},
|
||||
user.id,
|
||||
GOOGLE_CALENDAR_ID
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||
const body = {
|
||||
id: 10,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: Promise<DeletedCalendarCredentialsOutputResponseDto> = response.body;
|
||||
|
||||
expect((await responseBody).status).toEqual(SUCCESS_STATUS);
|
||||
expect((await responseBody).data).toBeDefined();
|
||||
expect((await responseBody).data.id).toEqual(body.id);
|
||||
expect((await responseBody).data.userId).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import {
|
||||
DeletedCalendarCredentialsOutputResponseDto,
|
||||
DeletedCalendarCredentialsOutputDto,
|
||||
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Query,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Req,
|
||||
Param,
|
||||
Headers,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_READ } from "@calcom/platform-constants";
|
||||
import {
|
||||
SUCCESS_STATUS,
|
||||
CALENDARS,
|
||||
GOOGLE_CALENDAR,
|
||||
OFFICE_365_CALENDAR,
|
||||
APPLE_CALENDAR,
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Calendars")
|
||||
export class CalendarsController {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
private readonly appleCalendarService: AppleCalendarService,
|
||||
private readonly calendarsRepository: CalendarsRepository
|
||||
) {}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Get("/busy-times")
|
||||
async getBusyTimes(
|
||||
@Query() queryParams: CalendarBusyTimesInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBusyTimesOutput> {
|
||||
const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams;
|
||||
if (!dateFrom || !dateTo) {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const busyTimes = await this.calendarsService.getBusyTimes(
|
||||
calendarsToLoad,
|
||||
user.id,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
loggedInUsersTz
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: busyTimes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||
const calendars = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: calendars,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string,
|
||||
@Query("redir") redir?: string | null
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir } = z
|
||||
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
||||
.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
});
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/credentials")
|
||||
async syncCredentials(
|
||||
@GetUser() user: User,
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: { username: string; password: string }
|
||||
): Promise<{ status: string }> {
|
||||
const { username, password } = body;
|
||||
|
||||
switch (calendar) {
|
||||
case APPLE_CALENDAR:
|
||||
return await this.appleCalendarService.save(user.id, user.email, username, password);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:calendar/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.check(userId);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.check(userId);
|
||||
case APPLE_CALENDAR:
|
||||
return await this.appleCalendarService.check(userId);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
CALENDARS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Post("/:calendar/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteCalendarCredentials(
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
|
||||
const { id: credentialId } = body;
|
||||
await this.calendarsService.checkCalendarCredentials(credentialId, user.id);
|
||||
|
||||
const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials(
|
||||
credentialId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(
|
||||
DeletedCalendarCredentialsOutputDto,
|
||||
{ id, type, userId, teamId, appId, invalid },
|
||||
{ strategy: "excludeAll" }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsInt } from "class-validator";
|
||||
|
||||
export class DeleteCalendarCredentialsInputBodyDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: 10,
|
||||
description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint",
|
||||
type: "integer",
|
||||
required: true,
|
||||
})
|
||||
readonly id!: number;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class BusyTimesOutput {
|
||||
@IsDate()
|
||||
start!: Date;
|
||||
|
||||
@IsDate()
|
||||
end!: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string | null;
|
||||
}
|
||||
|
||||
export class GetBusyTimesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => BusyTimesOutput)
|
||||
@IsArray()
|
||||
data!: BusyTimesOutput[];
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Integration {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
appData?: object | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dirName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
__template?: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
installed?: boolean;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
variant!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categories!: string[];
|
||||
|
||||
@IsString()
|
||||
logo!: string;
|
||||
|
||||
@IsString()
|
||||
publisher!: string;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsUrl()
|
||||
url!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
locationOption!: object | null;
|
||||
}
|
||||
|
||||
class Primary {
|
||||
@IsEmail()
|
||||
externalId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
integration?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
primary!: boolean | null;
|
||||
|
||||
@IsBoolean()
|
||||
readOnly!: boolean;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsBoolean()
|
||||
isSelected!: boolean;
|
||||
|
||||
@IsInt()
|
||||
credentialId!: number;
|
||||
}
|
||||
|
||||
class Calendar {
|
||||
@IsEmail()
|
||||
externalId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
integration?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
primary?: boolean | null;
|
||||
|
||||
@IsBoolean()
|
||||
readOnly!: boolean;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsBoolean()
|
||||
isSelected!: boolean;
|
||||
|
||||
@IsInt()
|
||||
credentialId!: number;
|
||||
}
|
||||
|
||||
class ConnectedCalendar {
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
integration!: Integration;
|
||||
|
||||
@IsInt()
|
||||
credentialId!: number;
|
||||
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
primary?: Primary;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
calendars?: Calendar[];
|
||||
}
|
||||
|
||||
class DestinationCalendar {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
integration!: string;
|
||||
|
||||
@IsEmail()
|
||||
externalId!: string;
|
||||
|
||||
@IsEmail()
|
||||
primaryEmail!: string | null;
|
||||
|
||||
@IsInt()
|
||||
userId!: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
eventTypeId!: number | null;
|
||||
|
||||
@IsInt()
|
||||
credentialId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
primary?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
readOnly?: boolean;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
integrationTitle?: string;
|
||||
}
|
||||
|
||||
class ConnectedCalendarsData {
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
connectedCalendars!: ConnectedCalendar[];
|
||||
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
destinationCalendar!: DestinationCalendar;
|
||||
}
|
||||
export class ConnectedCalendarsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ConnectedCalendarsData)
|
||||
data!: ConnectedCalendarsData;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeletedCalendarCredentialsOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly id!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly type!: string;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly userId!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly teamId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly appId!: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly invalid!: boolean | null;
|
||||
}
|
||||
|
||||
export class DeletedCalendarCredentialsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => DeletedCalendarCredentialsOutputDto)
|
||||
data!: DeletedCalendarCredentialsOutputDto;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
@Injectable()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async save(
|
||||
userId: number,
|
||||
userEmail: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ status: string }> {
|
||||
return await this.saveCalendarCredentials(userId, userEmail, username, password);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!appleCalendarCredentials) {
|
||||
throw new BadRequestException("Credentials for apple calendar not found.");
|
||||
}
|
||||
|
||||
if (appleCalendarCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid apple calendar credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const appleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!appleCalendar) {
|
||||
throw new UnauthorizedException("Apple calendar not connected.");
|
||||
}
|
||||
if (appleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(appleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
|
||||
const data = {
|
||||
type: APPLE_CALENDAR_TYPE,
|
||||
key: symmetricEncrypt(
|
||||
JSON.stringify({ username, password }),
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
),
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const dav = new CalendarService({
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import {
|
||||
CredentialsRepository,
|
||||
CredentialsWithUserEmail,
|
||||
} from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
|
||||
import { Calendar } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsService {
|
||||
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId);
|
||||
if (!userWithCalendars) {
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
|
||||
return getConnectedDestinationCalendars(
|
||||
userWithCalendars,
|
||||
false,
|
||||
this.dbWrite.prisma as unknown as PrismaClient
|
||||
);
|
||||
}
|
||||
|
||||
async getBusyTimes(
|
||||
calendarsToLoad: Calendar[],
|
||||
userId: User["id"],
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
timezone: string
|
||||
) {
|
||||
const credentials = await this.getUniqCalendarCredentials(calendarsToLoad, userId);
|
||||
const composedSelectedCalendars = await this.getCalendarsWithCredentials(
|
||||
credentials,
|
||||
calendarsToLoad,
|
||||
userId
|
||||
);
|
||||
try {
|
||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||
"",
|
||||
credentials,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
composedSelectedCalendars
|
||||
);
|
||||
const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => {
|
||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
};
|
||||
});
|
||||
return calendarBusyTimesConverted;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
"Unable to fetch connected calendars events. Please try again later."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
|
||||
const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId)));
|
||||
const credentials = await this.credentialsRepository.getUserCredentialsByIds(userId, uniqueCredentialIds);
|
||||
|
||||
if (credentials.length !== uniqueCredentialIds.length) {
|
||||
throw new UnauthorizedException("These credentials do not belong to you");
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async getCalendarsWithCredentials(
|
||||
credentials: CredentialsWithUserEmail,
|
||||
calendarsToLoad: Calendar[],
|
||||
userId: User["id"]
|
||||
) {
|
||||
const composedSelectedCalendars = calendarsToLoad.map((calendar) => {
|
||||
const credential = credentials.find((item) => item.id === calendar.credentialId);
|
||||
if (!credential) {
|
||||
throw new UnauthorizedException("These credentials do not belong to you");
|
||||
}
|
||||
return {
|
||||
...calendar,
|
||||
userId,
|
||||
integration: credential.type,
|
||||
};
|
||||
});
|
||||
return composedSelectedCalendars;
|
||||
}
|
||||
|
||||
async getAppKeys(appName: string) {
|
||||
const app = await this.appsRepository.getAppBySlug(appName);
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async checkCalendarCredentials(credentialId: number, userId: number) {
|
||||
const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId);
|
||||
if (!credential) {
|
||||
throw new NotFoundException("Calendar credentials not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
162
calcom/apps/api/v2/src/ee/calendars/services/gcal.service.ts
Normal file
162
calcom/apps/api/v2/src/ee/calendars/services/gcal.service.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
const CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||
private logger = new Logger("GcalService");
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
async getOAuthClient(redirectUri: string) {
|
||||
this.logger.log("Getting Google Calendar OAuth Client");
|
||||
const app = await this.appsRepository.getAppBySlug("google-calendar");
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const googleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!googleCalendar) {
|
||||
throw new UnauthorizedException("Google Calendar not connected.");
|
||||
}
|
||||
if (googleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(googleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
) {
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
if (!code) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||
|
||||
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||
|
||||
if (primaryCal?.id) {
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return { url: redir || origin };
|
||||
}
|
||||
}
|
||||
189
calcom/apps/api/v2/src/ee/calendars/services/outlook.service.ts
Normal file
189
calcom/apps/api/v2/src/ee/calendars/services/outlook.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
SUCCESS_STATUS,
|
||||
OFFICE_365_CALENDAR,
|
||||
OFFICE_365_CALENDAR_ID,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class OutlookService implements OAuthCalendarApp {
|
||||
private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
) {}
|
||||
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
const params = {
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!office365CalendarCredentials) {
|
||||
throw new BadRequestException("Credentials for office_365_calendar not found.");
|
||||
}
|
||||
|
||||
if (office365CalendarCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid office 365 calendar credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const office365Calendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
if (!office365Calendar) {
|
||||
throw new UnauthorizedException("Office 365 calendar not connected.");
|
||||
}
|
||||
if (office365Calendar.error?.message) {
|
||||
throw new UnauthorizedException(office365Calendar.error?.message);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
async getOAuthCredentials(code: string) {
|
||||
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
|
||||
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
Object.keys(payload)
|
||||
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
|
||||
.join("&");
|
||||
|
||||
const body = toUrlEncoded({
|
||||
client_id,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: scopes.join(" "),
|
||||
redirect_uri: this.redirectUri,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
|
||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const responseBody = await response.json();
|
||||
|
||||
return responseBody as OfficeCalendar;
|
||||
}
|
||||
|
||||
async saveCalendarCredentialsAndRedirect(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
) {
|
||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||
if (!code) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!ownerId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
|
||||
|
||||
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||
|
||||
if (defaultCalendar?.id) {
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
office365OAuthCredentials,
|
||||
ownerId
|
||||
);
|
||||
|
||||
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||
defaultCalendar.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: redir || origin,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const DEFAULT_EVENT_TYPES = {
|
||||
thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" },
|
||||
thirtyMinutesVideo: {
|
||||
length: 30,
|
||||
slug: "thirty-minutes-video",
|
||||
title: "30 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" },
|
||||
sixtyMinutesVideo: {
|
||||
length: 60,
|
||||
slug: "sixty-minutes-video",
|
||||
title: "60 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { Editable } from "@/ee/event-types/event-types_2024_04_15//inputs/enums/editable";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
|
||||
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
|
||||
import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
|
||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { EventType, 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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import {
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_04_15,
|
||||
CAL_API_VERSION_HEADER,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
EventTypesByViewer,
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
describe("Not authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it(`/GET/:id`, () => {
|
||||
return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
|
||||
const userEmail = "event-types-test-e2e@api.com";
|
||||
const name = "bob-the-builder";
|
||||
const username = name;
|
||||
let eventType: EventType;
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
name,
|
||||
username,
|
||||
});
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_04_15 = {
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
description: "A description of the test event type.",
|
||||
length: 60,
|
||||
hidden: false,
|
||||
disableGuests: true,
|
||||
slotInterval: 15,
|
||||
afterEventBuffer: 5,
|
||||
beforeEventBuffer: 10,
|
||||
minimumBookingNotice: 120,
|
||||
locations: [
|
||||
{
|
||||
type: "Online",
|
||||
link: "https://example.com/meet",
|
||||
displayLocationPublicly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
expect(responseBody.data).toHaveProperty("id");
|
||||
expect(responseBody.data.title).toEqual(body.title);
|
||||
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
|
||||
expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
|
||||
expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
|
||||
expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
|
||||
expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
|
||||
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update event type", async () => {
|
||||
const newTitle = "Updated title";
|
||||
|
||||
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||
title: newTitle,
|
||||
disableGuests: false,
|
||||
slotInterval: 30,
|
||||
afterEventBuffer: 10,
|
||||
beforeEventBuffer: 15,
|
||||
minimumBookingNotice: 240,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
expect(responseBody.data.title).toEqual(newTitle);
|
||||
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
|
||||
expect(responseBody.data.slotInterval).toEqual(body.slotInterval);
|
||||
expect(responseBody.data.minimumBookingNotice).toEqual(body.minimumBookingNotice);
|
||||
expect(responseBody.data.beforeEventBuffer).toEqual(body.beforeEventBuffer);
|
||||
expect(responseBody.data.afterEventBuffer).toEqual(body.afterEventBuffer);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.disableGuests = responseBody.data.disableGuests ?? false;
|
||||
eventType.slotInterval = body.slotInterval ?? null;
|
||||
eventType.minimumBookingNotice = body.minimumBookingNotice ?? 10;
|
||||
eventType.beforeEventBuffer = body.beforeEventBuffer ?? 10;
|
||||
eventType.afterEventBuffer = body.afterEventBuffer ?? 10;
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 if param event type id is null", async () => {
|
||||
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||
locations,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400);
|
||||
});
|
||||
|
||||
it("should update event type locations", async () => {
|
||||
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||
|
||||
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||
locations,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
const responseLocations = eventTypeLocations.parse(responseBody.data.locations);
|
||||
expect(responseLocations).toBeDefined();
|
||||
expect(responseLocations.length).toEqual(locations.length);
|
||||
expect(responseLocations).toEqual(locations);
|
||||
eventType.locations = responseLocations;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update event type bookingFields", async () => {
|
||||
const bookingFieldName = "location-name";
|
||||
const bookingFields = [
|
||||
{
|
||||
name: bookingFieldName,
|
||||
type: BaseField.radio,
|
||||
label: "Location",
|
||||
options: [
|
||||
{
|
||||
label: "Via Bari 10, Roma, 90119, Italy",
|
||||
value: "Via Bari 10, Roma, 90119, Italy",
|
||||
},
|
||||
{
|
||||
label: "Via Reale 28, Roma, 9001, Italy",
|
||||
value: "Via Reale 28, Roma, 9001, Italy",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: Editable.user,
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||
bookingFields,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields);
|
||||
expect(responseBookingFields).toBeDefined();
|
||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
||||
eventType.bookingFields = responseBookingFields;
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypeOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.eventType.id).toEqual(eventType.id);
|
||||
expect(responseBody.data.eventType.title).toEqual(eventType.title);
|
||||
expect(responseBody.data.eventType.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data.eventType.userId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/:id with version VERSION_2024_06_11`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypeOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.eventType.id).toEqual(eventType.id);
|
||||
expect(responseBody.data.eventType.title).toEqual(eventType.title);
|
||||
expect(responseBody.data.eventType.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data.eventType.userId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/:username/public`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/public`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypesPublicOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
expect(responseBody.data?.[0]?.id).toEqual(eventType.id);
|
||||
expect(responseBody.data?.[0]?.title).toEqual(eventType.title);
|
||||
expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data?.[0]?.length).toEqual(eventType.length);
|
||||
});
|
||||
|
||||
it(`/GET/:username/:eventSlug/public`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetEventTypePublicOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.id).toEqual(eventType.id);
|
||||
expect(responseBody.data?.title).toEqual(eventType.title);
|
||||
expect(responseBody.data?.slug).toEqual(eventType.slug);
|
||||
expect(responseBody.data?.length).toEqual(eventType.length);
|
||||
});
|
||||
|
||||
it(`/GET/`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.eventTypeGroups).toBeDefined();
|
||||
expect(responseBody.data.eventTypeGroups).toBeDefined();
|
||||
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
|
||||
expect(responseBody.data.eventTypeGroups[0].profile).toBeDefined();
|
||||
expect(responseBody.data.eventTypeGroups?.[0]?.profile?.name).toEqual(name);
|
||||
expect(responseBody.data.eventTypeGroups?.[0]?.eventTypes?.[0]?.id).toEqual(eventType.id);
|
||||
expect(responseBody.data.profiles?.[0]?.name).toEqual(name);
|
||||
});
|
||||
|
||||
it(`/GET/public/:username/`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${username}/public`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypesPublic> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].id).toEqual(eventType.id);
|
||||
});
|
||||
|
||||
it(`/GET/:id not existing`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/1000`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { EventTypeIdParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input";
|
||||
import { GetPublicEventTypeQueryParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input";
|
||||
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||
import { CreateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output";
|
||||
import { DeleteEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output";
|
||||
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
|
||||
import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
|
||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output";
|
||||
import {
|
||||
GetEventTypesData,
|
||||
GetEventTypesOutput,
|
||||
} from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11 } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Body,
|
||||
NotFoundException,
|
||||
Patch,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/event-types",
|
||||
version: [VERSION_2024_04_15, VERSION_2024_06_11],
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_04_15,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.createUserEventType(user, body);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getEventType(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
|
||||
const eventTypes = await getEventTypesByViewer({
|
||||
id: user.id,
|
||||
profile: {
|
||||
upId: `usr-${user.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes as GetEventTypesData,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:username/:eventSlug/public")
|
||||
async getPublicEventType(
|
||||
@Param("username") username: string,
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
||||
): Promise<GetEventTypePublicOutput> {
|
||||
try {
|
||||
const event = await getPublicEvent(
|
||||
username.toLowerCase(),
|
||||
eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw new NotFoundException(err.message);
|
||||
}
|
||||
}
|
||||
throw new InternalServerErrorException("Could not find public event.");
|
||||
}
|
||||
|
||||
@Get("/:username/public")
|
||||
async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
|
||||
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateEventType(
|
||||
@Param() params: EventTypeIdParams_2024_04_15,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeInput_2024_04_15,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<UpdateEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async deleteEventType(
|
||||
@Param() params: EventTypeIdParams_2024_04_15,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<DeleteEventTypeOutput> {
|
||||
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
id: eventType.id,
|
||||
length: eventType.length,
|
||||
slug: eventType.slug,
|
||||
title: eventType.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/controllers/event-types.controller";
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||
controllers: [EventTypesController_2024_04_15],
|
||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||
})
|
||||
export class EventTypesModule_2024_04_15 {}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createUserEventType(
|
||||
userId: number,
|
||||
body: Pick<CreateEventTypeInput_2024_04_15, "title" | "slug" | "length" | "hidden">
|
||||
) {
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
data: {
|
||||
...body,
|
||||
userId,
|
||||
users: { connect: { id: userId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeWithSeats(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
select: { users: { select: { id: true } }, seatsPerTimeSlot: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventType(userId: number, eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(
|
||||
user: UserWithProfile,
|
||||
isUserOrganizationAdmin: boolean,
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } });
|
||||
}
|
||||
|
||||
async getUserEventTypeBySlug(userId: number, slug: string) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
userId_slug: {
|
||||
userId: userId,
|
||||
slug: slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number) {
|
||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
Min,
|
||||
IsArray,
|
||||
IsInt,
|
||||
} from "class-validator";
|
||||
|
||||
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
|
||||
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
|
||||
export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!";
|
||||
export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
|
||||
"Discover the culinary wonders of the Argentina by making the best flan ever!";
|
||||
|
||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||
// Just uncomment any below when requested.
|
||||
export class CreateEventTypeInput_2024_04_15 {
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||
length!: number;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
|
||||
slug!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
hidden?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeLocation_2024_04_15)
|
||||
@IsArray()
|
||||
locations?: EventTypeLocation_2024_04_15[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
disableGuests?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
slotInterval?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
minimumBookingNotice?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
beforeEventBuffer?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
afterEventBuffer?: number;
|
||||
|
||||
// @ApiHideProperty()
|
||||
// @IsOptional()
|
||||
// @IsNumber()
|
||||
// teamId?: number;
|
||||
|
||||
// @ApiHideProperty()
|
||||
// @IsOptional()
|
||||
// @IsEnum(SchedulingType)
|
||||
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum Editable {
|
||||
system = "system",
|
||||
systemButOptional = "system-but-optional",
|
||||
systemButHidden = "system-but-hidden",
|
||||
user = "user",
|
||||
userReadonly = "user-readonly",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export enum BaseField {
|
||||
number = "number",
|
||||
boolean = "boolean",
|
||||
address = "address",
|
||||
name = "name",
|
||||
text = "text",
|
||||
textarea = "textarea",
|
||||
email = "email",
|
||||
phone = "phone",
|
||||
multiemail = "multiemail",
|
||||
select = "select",
|
||||
multiselect = "multiselect",
|
||||
checkbox = "checkbox",
|
||||
radio = "radio",
|
||||
radioInput = "radioInput",
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum Frequency {
|
||||
YEARLY = 0,
|
||||
MONTHLY = 1,
|
||||
WEEKLY = 2,
|
||||
DAILY = 3,
|
||||
HOURLY = 4,
|
||||
MINUTELY = 5,
|
||||
SECONDLY = 6,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum PeriodType {
|
||||
UNLIMITED = "UNLIMITED",
|
||||
ROLLING = "ROLLING",
|
||||
RANGE = "RANGE",
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum SchedulingType {
|
||||
ROUND_ROBIN = "ROUND_ROBIN",
|
||||
COLLECTIVE = "COLLECTIVE",
|
||||
MANAGED = "MANAGED",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsNumberString } from "class-validator";
|
||||
|
||||
export class EventTypeIdParams_2024_04_15 {
|
||||
@IsNumberString()
|
||||
eventTypeId!: number;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
|
||||
|
||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||
// Just uncomment any below when requested.
|
||||
|
||||
export class EventTypeLocation_2024_04_15 {
|
||||
@IsString()
|
||||
@DocsProperty({ example: "link" })
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
|
||||
link?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
displayLocationPublicly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
hostPhoneNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@ApiHideProperty()
|
||||
credentialId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
teamName?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class GetPublicEventTypeQueryParams_2024_04_15 {
|
||||
@Transform(({ value }: { value: string }) => value === "true")
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiProperty({ required: false })
|
||||
isTeamEvent?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
org?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/editable";
|
||||
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
|
||||
import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency";
|
||||
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
Min,
|
||||
IsInt,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsDate,
|
||||
IsNumber,
|
||||
} from "class-validator";
|
||||
|
||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
|
||||
|
||||
class Option {
|
||||
@IsString()
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
label!: string;
|
||||
}
|
||||
|
||||
class Source {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
editUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fieldRequired?: boolean;
|
||||
}
|
||||
|
||||
class View {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
class OptionsInput {
|
||||
@IsString()
|
||||
type!: "address" | "text" | "phone";
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
class VariantField {
|
||||
@IsString()
|
||||
type!: BaseField;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
labelAsSafeHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
placeholder?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
class Variant {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => VariantField)
|
||||
fields!: VariantField[];
|
||||
}
|
||||
|
||||
class VariantsConfig {
|
||||
variants!: Record<string, Variant>;
|
||||
}
|
||||
|
||||
export class BookingField_2024_04_15 {
|
||||
@IsEnum(BaseField)
|
||||
type!: BaseField;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Option)
|
||||
options?: Option[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
labelAsSafeHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
placeholder?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
getOptionsAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OptionsInput)
|
||||
optionsInputs?: Record<string, OptionsInput>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
variant?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => VariantsConfig)
|
||||
variantsConfig?: VariantsConfig;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => View)
|
||||
views?: View[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hideWhenJustOneOption?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hidden?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Editable)
|
||||
editable?: Editable;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Source)
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
export class RecurringEvent_2024_04_15 {
|
||||
@IsDate()
|
||||
@IsOptional()
|
||||
dtstart?: Date;
|
||||
|
||||
@IsInt()
|
||||
interval!: number;
|
||||
|
||||
@IsInt()
|
||||
count!: number;
|
||||
|
||||
@IsEnum(Frequency)
|
||||
freq!: Frequency;
|
||||
|
||||
@IsDate()
|
||||
@IsOptional()
|
||||
until?: Date;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tzid?: string;
|
||||
}
|
||||
|
||||
export class IntervalLimits_2024_04_15 {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
PER_DAY?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
PER_WEEK?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
PER_MONTH?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
PER_YEAR?: number;
|
||||
}
|
||||
|
||||
export class UpdateEventTypeInput_2024_04_15 {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
length?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
hidden?: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeLocation_2024_04_15)
|
||||
@IsOptional()
|
||||
locations?: EventTypeLocation_2024_04_15[];
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// position?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// offsetStart?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// userId?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// profileId?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// teamId?: number;
|
||||
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
// eventName?: string;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// parentId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookingField_2024_04_15)
|
||||
bookingFields?: BookingField_2024_04_15[];
|
||||
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
// timeZone?: string;
|
||||
|
||||
// @IsEnum(PeriodType)
|
||||
// @IsOptional()
|
||||
// periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
|
||||
|
||||
// @IsDate()
|
||||
// @IsOptional()
|
||||
// periodStartDate?: Date;
|
||||
|
||||
// @IsDate()
|
||||
// @IsOptional()
|
||||
// periodEndDate?: Date;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// periodDays?: number;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// periodCountCalendarDays?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// lockTimeZoneToggleOnBookingPage?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// requiresConfirmation?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// requiresBookerEmailVerification?: boolean;
|
||||
|
||||
// @ValidateNested()
|
||||
// @Type(() => RecurringEvent)
|
||||
// @IsOptional()
|
||||
// recurringEvent?: RecurringEvent;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
disableGuests?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// hideCalendarNotes?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
minimumBookingNotice?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
beforeEventBuffer?: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
afterEventBuffer?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// seatsPerTimeSlot?: number;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// onlyShowFirstAvailableSlot?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// seatsShowAttendees?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// seatsShowAvailabilityCount?: boolean;
|
||||
|
||||
// @IsEnum(SchedulingType)
|
||||
// @IsOptional()
|
||||
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// scheduleId?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// price?: number;
|
||||
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
// currency?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
slotInterval?: number;
|
||||
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
// @IsUrl()
|
||||
// successRedirectUrl?: string;
|
||||
|
||||
// @ValidateNested()
|
||||
// @Type(() => IntervalLimits)
|
||||
// @IsOptional()
|
||||
// bookingLimits?: IntervalLimits;
|
||||
|
||||
// @ValidateNested()
|
||||
// @Type(() => IntervalLimits)
|
||||
// @IsOptional()
|
||||
// durationLimits?: IntervalLimits;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// isInstantEvent?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// assignAllTeamMembers?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// useEventTypeDestinationCalendarEmail?: boolean;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// secondaryEmailId?: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateEventTypeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput)
|
||||
data!: EventTypeOutput;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CREATE_EVENT_LENGTH_EXAMPLE,
|
||||
CREATE_EVENT_SLUG_EXAMPLE,
|
||||
CREATE_EVENT_TITLE_EXAMPLE,
|
||||
} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class DeleteData {
|
||||
@IsInt()
|
||||
@DocsProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||
length!: number;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
|
||||
slug!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
|
||||
title!: string;
|
||||
}
|
||||
|
||||
export class DeleteEventTypeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Type(() => DeleteData)
|
||||
data!: DeleteData;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
CREATE_EVENT_DESCRIPTION_EXAMPLE,
|
||||
CREATE_EVENT_LENGTH_EXAMPLE,
|
||||
CREATE_EVENT_SLUG_EXAMPLE,
|
||||
CREATE_EVENT_TITLE_EXAMPLE,
|
||||
} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { PeriodType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/period-type";
|
||||
import { SchedulingType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type";
|
||||
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||
import {
|
||||
BookingField_2024_04_15,
|
||||
IntervalLimits_2024_04_15,
|
||||
RecurringEvent_2024_04_15,
|
||||
} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsJSON,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
export class EventTypeOutput {
|
||||
@IsInt()
|
||||
@DocsProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||
length!: number;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
|
||||
slug!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
|
||||
description!: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
hidden!: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeLocation_2024_04_15)
|
||||
@IsArray()
|
||||
locations!: EventTypeLocation_2024_04_15[] | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
offsetStart!: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
userId!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
profileId?: number | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
teamId!: number | null;
|
||||
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
eventName!: string | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
parentId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookingField_2024_04_15)
|
||||
@ApiHideProperty()
|
||||
bookingFields!: BookingField_2024_04_15[] | null;
|
||||
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
timeZone!: string | null;
|
||||
|
||||
@IsEnum(PeriodType)
|
||||
@ApiHideProperty()
|
||||
periodType!: PeriodType | null;
|
||||
|
||||
@IsDate()
|
||||
@ApiHideProperty()
|
||||
periodStartDate!: Date | null;
|
||||
|
||||
@IsDate()
|
||||
@ApiHideProperty()
|
||||
periodEndDate!: Date | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
periodDays!: number | null;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
periodCountCalendarDays!: boolean | null;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
lockTimeZoneToggleOnBookingPage!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
requiresConfirmation!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
requiresBookerEmailVerification!: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => RecurringEvent_2024_04_15)
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
recurringEvent!: RecurringEvent_2024_04_15 | null;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
disableGuests!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
hideCalendarNotes!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
minimumBookingNotice!: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
beforeEventBuffer!: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
afterEventBuffer!: number;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
seatsPerTimeSlot!: number | null;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
onlyShowFirstAvailableSlot!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
seatsShowAttendees!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
seatsShowAvailabilityCount!: boolean;
|
||||
|
||||
@IsEnum(SchedulingType)
|
||||
@ApiHideProperty()
|
||||
schedulingType!: SchedulingType | null;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
scheduleId?: number | null;
|
||||
|
||||
@IsNumber()
|
||||
@ApiHideProperty()
|
||||
price!: number;
|
||||
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
currency!: string;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
slotInterval!: number | null;
|
||||
|
||||
@IsJSON()
|
||||
@ApiHideProperty()
|
||||
metadata!: Record<string, any> | null;
|
||||
|
||||
@IsString()
|
||||
@ApiHideProperty()
|
||||
successRedirectUrl!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => IntervalLimits_2024_04_15)
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
bookingLimits!: IntervalLimits_2024_04_15;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => IntervalLimits_2024_04_15)
|
||||
@ApiHideProperty()
|
||||
durationLimits!: IntervalLimits_2024_04_15;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
isInstantEvent!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
assignAllTeamMembers!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@ApiHideProperty()
|
||||
useEventTypeDestinationCalendarEmail!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@ApiHideProperty()
|
||||
secondaryEmailId!: number | null;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
} from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Location {
|
||||
@IsString()
|
||||
type!: string;
|
||||
}
|
||||
|
||||
class Source {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
label!: string;
|
||||
}
|
||||
|
||||
class OptionInput {
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
required?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
class BookingField {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultLabel?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
placeholder?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
getOptionsAt?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
optionsInputs?: { [key: string]: OptionInput };
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
hideWhenJustOneOption?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
editable?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Source)
|
||||
@IsOptional()
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
class Organization {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
slug?: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata!: Record<string, any>;
|
||||
}
|
||||
|
||||
class Profile {
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsInt()
|
||||
id!: number | null;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
userId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
uid?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId!: number | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Organization)
|
||||
organization?: Organization | null;
|
||||
|
||||
@IsString()
|
||||
upId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
image?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
brandColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
darkBrandColor?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
theme?: string;
|
||||
|
||||
@IsOptional()
|
||||
bookerLayouts?: any;
|
||||
}
|
||||
|
||||
class Owner {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
weekStart!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
brandColor?: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
darkBrandColor?: string | null;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
theme?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
metadata!: any;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
defaultScheduleId?: number | null;
|
||||
|
||||
@IsString()
|
||||
nonProfileUsername!: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Profile)
|
||||
profile!: Profile;
|
||||
}
|
||||
|
||||
class User {
|
||||
@IsString()
|
||||
username!: string | null;
|
||||
|
||||
@IsString()
|
||||
name!: string | null;
|
||||
|
||||
@IsString()
|
||||
weekStart!: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@ValidateNested()
|
||||
profile!: Profile;
|
||||
|
||||
@IsString()
|
||||
bookerUrl!: string;
|
||||
}
|
||||
|
||||
class Schedule {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string | null;
|
||||
}
|
||||
|
||||
class PublicEventTypeOutput {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
eventName?: string | null;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsBoolean()
|
||||
isInstantEvent!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
aiPhoneCallConfig?: any;
|
||||
|
||||
@IsOptional()
|
||||
schedulingType?: any;
|
||||
|
||||
@IsInt()
|
||||
length!: number;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Location)
|
||||
locations!: Location[];
|
||||
|
||||
@IsArray()
|
||||
customInputs!: any[];
|
||||
|
||||
@IsBoolean()
|
||||
disableGuests!: boolean;
|
||||
|
||||
@IsObject()
|
||||
metadata!: object | null;
|
||||
|
||||
@IsBoolean()
|
||||
lockTimeZoneToggleOnBookingPage!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
requiresConfirmation!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
requiresBookerEmailVerification!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
recurringEvent?: any;
|
||||
|
||||
@IsNumber()
|
||||
price!: number;
|
||||
|
||||
@IsString()
|
||||
currency!: string;
|
||||
|
||||
@IsOptional()
|
||||
seatsPerTimeSlot?: number | null;
|
||||
|
||||
@IsBoolean()
|
||||
seatsShowAvailabilityCount!: boolean | null;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BookingField)
|
||||
bookingFields!: BookingField[];
|
||||
|
||||
@IsOptional()
|
||||
team?: any;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
successRedirectUrl?: string | null;
|
||||
|
||||
@IsArray()
|
||||
workflows!: any[];
|
||||
|
||||
@IsArray()
|
||||
hosts!: any[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Owner)
|
||||
owner!: Owner | null;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => Schedule)
|
||||
schedule!: Schedule | null;
|
||||
|
||||
@IsBoolean()
|
||||
hidden!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
assignAllTeamMembers!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
bookerLayouts?: any;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => User)
|
||||
users!: User[];
|
||||
|
||||
@IsObject()
|
||||
entity!: object;
|
||||
|
||||
@IsBoolean()
|
||||
isDynamic!: boolean;
|
||||
}
|
||||
|
||||
export class GetEventTypePublicOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PublicEventTypeOutput)
|
||||
@IsArray()
|
||||
data!: PublicEventTypeOutput | null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class Data {
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput)
|
||||
eventType!: EventTypeOutput;
|
||||
}
|
||||
|
||||
export class GetEventTypeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: Data,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Data)
|
||||
data!: Data;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CREATE_EVENT_LENGTH_EXAMPLE,
|
||||
CREATE_EVENT_SLUG_EXAMPLE,
|
||||
CREATE_EVENT_TITLE_EXAMPLE,
|
||||
} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsInt, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class PublicEventType {
|
||||
@IsInt()
|
||||
@DocsProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||
length!: number;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
|
||||
slug!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export class GetEventTypesPublicOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PublicEventType)
|
||||
@IsArray()
|
||||
data!: PublicEventType[];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class EventTypeGroup {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeOutput)
|
||||
@IsArray()
|
||||
eventTypes!: EventTypeOutput[];
|
||||
}
|
||||
|
||||
export class GetEventTypesData {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeGroup)
|
||||
@IsArray()
|
||||
eventTypeGroups!: EventTypeGroup[];
|
||||
}
|
||||
|
||||
export class GetEventTypesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => GetEventTypesData)
|
||||
@IsArray()
|
||||
data!: GetEventTypesData;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateEventTypeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput)
|
||||
data!: EventTypeOutput;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
createEventType,
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_04_15,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async createUserEventType(
|
||||
user: UserWithProfile,
|
||||
body: CreateEventTypeInput_2024_04_15
|
||||
): Promise<EventTypeOutput> {
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
const { eventType } = await createEventType({
|
||||
input: body,
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
return eventType as EventTypeOutput;
|
||||
}
|
||||
|
||||
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_04_15) {
|
||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||
if (existsWithSlug) {
|
||||
throw new BadRequestException("User already has an event type with this slug.");
|
||||
}
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
organization: { isOrgAdmin },
|
||||
profile: { id: profileId },
|
||||
metadata: user.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventType(userId: number, eventTypeId: number) {
|
||||
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(userId, eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
|
||||
user,
|
||||
isUserOrganizationAdmin,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType as { eventType: EventTypeOutput };
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username "${username}" not found`);
|
||||
}
|
||||
|
||||
return await getEventTypesPublic(user.id);
|
||||
}
|
||||
|
||||
async createUserDefaultEventTypes(userId: number) {
|
||||
const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES;
|
||||
|
||||
const defaultEventTypes = await Promise.all([
|
||||
this.eventTypesRepository.createUserEventType(userId, thirtyMinutes),
|
||||
this.eventTypesRepository.createUserEventType(userId, sixtyMinutes),
|
||||
this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo),
|
||||
this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo),
|
||||
]);
|
||||
|
||||
return defaultEventTypes;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
|
||||
this.checkCanUpdateEventType(user.id, eventTypeId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
await updateEventType({
|
||||
input: { id: eventTypeId, ...body },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
const eventType = await this.getUserEventTypeForAtom(user, eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
|
||||
const existingEventType = await this.getUserEventType(userId, eventTypeId);
|
||||
if (!existingEventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
this.checkUserOwnsEventType(userId, existingEventType);
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number, userId: number) {
|
||||
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
|
||||
if (!existingEventType) {
|
||||
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(userId, existingEventType);
|
||||
|
||||
return this.eventTypesRepository.deleteEventType(eventTypeId);
|
||||
}
|
||||
|
||||
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
|
||||
if (userId !== eventType.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const DEFAULT_EVENT_TYPES = {
|
||||
thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" },
|
||||
thirtyMinutesVideo: {
|
||||
length: 30,
|
||||
slug: "thirty-minutes-video",
|
||||
title: "30 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" },
|
||||
sixtyMinutesVideo: {
|
||||
length: 60,
|
||||
slug: "sixty-minutes-video",
|
||||
title: "60 Minutes",
|
||||
locations: [{ type: "integrations:daily" }],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,383 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Schedule } 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 { 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";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
describe("Not authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it(`/GET/:id`, () => {
|
||||
return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Authenticated", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "event-types-test-e2e@api.com";
|
||||
const falseTestEmail = "false-event-types@api.com";
|
||||
const name = "bob-the-builder";
|
||||
const username = name;
|
||||
let eventType: EventTypeOutput_2024_06_14;
|
||||
let user: User;
|
||||
let falseTestUser: User;
|
||||
let firstSchedule: Schedule;
|
||||
let secondSchedule: Schedule;
|
||||
let falseTestSchedule: Schedule;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
name,
|
||||
username,
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
email: falseTestEmail,
|
||||
name: "false-test",
|
||||
username: falseTestEmail,
|
||||
});
|
||||
|
||||
firstSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: user.id,
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
secondSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: user.id,
|
||||
name: "chill",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
falseTestSchedule = await schedulesRepostoryFixture.create({
|
||||
userId: falseTestUser.id,
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
});
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["redirect-uri"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with schedule user does not own", async () => {
|
||||
const scheduleId = falseTestSchedule.id;
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
bookingFields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language you want to learn",
|
||||
slug: "select-language",
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
scheduleId,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
bookingFields: [
|
||||
{
|
||||
type: "select",
|
||||
label: "select which language you want to learn",
|
||||
slug: "select-language",
|
||||
required: true,
|
||||
placeholder: "select language",
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
scheduleId: firstSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
|
||||
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update event type", async () => {
|
||||
const newTitle = "Coding class in Italian!";
|
||||
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
scheduleId: secondSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
expect(updatedEventType.title).toEqual(body.title);
|
||||
|
||||
expect(updatedEventType.id).toEqual(eventType.id);
|
||||
expect(updatedEventType.title).toEqual(newTitle);
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(updatedEventType.ownerId).toEqual(user.id);
|
||||
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow to update event type with scheduleId user does not own", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
scheduleId: falseTestSchedule.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(fetchedEventType.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType.ownerId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/even-types by username`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.[0];
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
const fetchedEventType = responseBody.data[0];
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
});
|
||||
|
||||
it(`/GET/:id not existing`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/1000`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete event type", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(falseTestUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { CreateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/create-event-type.output";
|
||||
import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/delete-event-type.output";
|
||||
import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output";
|
||||
import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output";
|
||||
import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Body,
|
||||
NotFoundException,
|
||||
Patch,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/event-types",
|
||||
version: VERSION_2024_06_14_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Event types")
|
||||
export class EventTypesController_2024_06_14 {
|
||||
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.createUserEventType(user, body);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
async getEventTypes(
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||
): Promise<GetEventTypesOutput_2024_06_14> {
|
||||
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
): Promise<DeleteEventTypeOutput_2024_06_14> {
|
||||
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
id: eventType.id,
|
||||
lengthInMinutes: eventType.length,
|
||||
slug: eventType.slug,
|
||||
title: eventType.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
controllers: [EventTypesController_2024_06_14],
|
||||
exports: [
|
||||
EventTypesService_2024_06_14,
|
||||
EventTypesRepository_2024_06_14,
|
||||
InputEventTypesService_2024_06_14,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
],
|
||||
})
|
||||
export class EventTypesModule_2024_06_14 {}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type InputEventTransformed = Omit<
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
"lengthInMinutes" | "locations" | "bookingFields"
|
||||
> & {
|
||||
length: number;
|
||||
slug: string;
|
||||
locations?: ReturnType<typeof transformApiEventTypeLocations>;
|
||||
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createUserEventType(userId: number, body: InputEventTransformed) {
|
||||
return this.dbWrite.prisma.eventType.create({
|
||||
data: {
|
||||
...body,
|
||||
userId,
|
||||
locations: body.locations,
|
||||
bookingFields: body.bookingFields,
|
||||
users: { connect: { id: userId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeWithSeats(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
select: { users: { select: { id: true } }, seatsPerTimeSlot: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventType(userId: number, eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
userId,
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(
|
||||
user: UserWithProfile,
|
||||
isUserOrganizationAdmin: boolean,
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
include: { users: true, schedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeBySlug(userId: number, slug: string) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
userId_slug: {
|
||||
userId: userId,
|
||||
slug: slug,
|
||||
},
|
||||
},
|
||||
include: { users: true, schedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number) {
|
||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class CreateEventTypeOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput_2024_06_14,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
data!: EventTypeOutput_2024_06_14;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsString } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { CREATE_EVENT_LENGTH_EXAMPLE, CREATE_EVENT_TITLE_EXAMPLE } from "@calcom/platform-types";
|
||||
|
||||
class DeleteData_2024_06_14 {
|
||||
@IsInt()
|
||||
@DocsProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||
lengthInMinutes!: number;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsString()
|
||||
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
|
||||
title!: string;
|
||||
}
|
||||
|
||||
export class DeleteEventTypeOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Type(() => DeleteData_2024_06_14)
|
||||
data!: DeleteData_2024_06_14;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class GetEventTypeOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput_2024_06_14,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
data!: EventTypeOutput_2024_06_14 | null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsIn, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class GetEventTypesOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
@IsArray()
|
||||
data!: EventTypeOutput_2024_06_14[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateEventTypeOutput_2024_06_14 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: EventTypeOutput_2024_06_14,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeOutput_2024_06_14)
|
||||
data!: EventTypeOutput_2024_06_14;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11
|
||||
) {}
|
||||
|
||||
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
const bodyTransformed = this.inputEventTypesService.transformInputCreateEventType(body);
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
input: bodyTransformed,
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeCreated.id);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`);
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
}
|
||||
|
||||
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_06_14) {
|
||||
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
|
||||
if (existsWithSlug) {
|
||||
throw new BadRequestException("User already has an event type with this slug.");
|
||||
}
|
||||
await this.checkUserOwnsSchedule(userId, body.scheduleId);
|
||||
}
|
||||
|
||||
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, eventTypeSlug);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
}
|
||||
|
||||
async getEventTypesByUsername(username: string) {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
return this.getUserEventTypes(user.id);
|
||||
}
|
||||
|
||||
async getUserToCreateEvent(user: UserWithProfile) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
const isOrgAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
organizationId: user.organizationId,
|
||||
organization: { isOrgAdmin },
|
||||
profile: { id: profileId },
|
||||
metadata: user.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEventType(userId: number, eventTypeId: number) {
|
||||
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(userId, eventType);
|
||||
return this.outputEventTypesService.getResponseEventType(userId, eventType);
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
|
||||
|
||||
const eventTypePromises = eventTypes.map(async (eventType) => {
|
||||
return await this.outputEventTypesService.getResponseEventType(userId, eventType);
|
||||
});
|
||||
|
||||
return await Promise.all(eventTypePromises);
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
|
||||
user,
|
||||
isUserOrganizationAdmin,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username "${username}" not found`);
|
||||
}
|
||||
|
||||
return await getEventTypesPublic(user.id);
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14): Promise<EventTypeOutput_2024_06_14[]> {
|
||||
const { username, eventSlug, usernames } = queryParams;
|
||||
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug);
|
||||
return eventType ? [eventType] : [];
|
||||
}
|
||||
|
||||
if (username) {
|
||||
return await this.getEventTypesByUsername(username);
|
||||
}
|
||||
|
||||
if (usernames) {
|
||||
const dynamicEventType = await this.getDynamicEventType(usernames);
|
||||
return [dynamicEventType];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDynamicEventType(usernames: string[]) {
|
||||
const users = await this.usersService.getByUsernames(usernames);
|
||||
const usersFiltered: UserWithProfile[] = [];
|
||||
for (const user of users) {
|
||||
if (user) {
|
||||
usersFiltered.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(0, {
|
||||
...dynamicEvent,
|
||||
users: usersFiltered,
|
||||
isInstantEvent: false,
|
||||
});
|
||||
}
|
||||
|
||||
async createUserDefaultEventTypes(userId: number) {
|
||||
const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES;
|
||||
|
||||
const defaultEventTypes = await Promise.all([
|
||||
this.eventTypesRepository.createUserEventType(userId, thirtyMinutes),
|
||||
this.eventTypesRepository.createUserEventType(userId, sixtyMinutes),
|
||||
this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo),
|
||||
this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo),
|
||||
]);
|
||||
|
||||
return defaultEventTypes;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
|
||||
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.getUserToUpdateEvent(user);
|
||||
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
|
||||
await updateEventType({
|
||||
input: { id: eventTypeId, ...bodyTransformed },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
|
||||
}
|
||||
|
||||
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
|
||||
const existingEventType = await this.getUserEventType(userId, eventTypeId);
|
||||
if (!existingEventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
|
||||
await this.checkUserOwnsSchedule(userId, scheduleId);
|
||||
}
|
||||
|
||||
async getUserToUpdateEvent(user: UserWithProfile) {
|
||||
const profileId = user.movedToProfile?.id || null;
|
||||
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
|
||||
return { ...user, profile: { id: profileId }, selectedCalendars };
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number, userId: number) {
|
||||
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
|
||||
if (!existingEventType) {
|
||||
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(userId, existingEventType);
|
||||
|
||||
return this.eventTypesRepository.deleteEventType(eventTypeId);
|
||||
}
|
||||
|
||||
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
|
||||
if (userId !== eventType.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async checkUserOwnsSchedule(userId: number, scheduleId: number | null | undefined) {
|
||||
if (!scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = await this.schedulesRepository.getScheduleByIdAndUserId(scheduleId, userId);
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
transformApiEventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
];
|
||||
|
||||
const { lengthInMinutes, locations, bookingFields, ...rest } = inputEventType;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: this.transformInputLocations(locations || defaultLocations),
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
|
||||
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
schedule: scheduleId,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformApiEventTypeLocations(inputLocations);
|
||||
}
|
||||
|
||||
transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
|
||||
return transformApiEventTypeBookingFields(inputBookingFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule } from "@prisma/client";
|
||||
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata,
|
||||
getResponseEventTypeLocations,
|
||||
getResponseEventTypeBookingFields,
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
|
||||
type Input = Pick<
|
||||
DatabaseEventType,
|
||||
| "id"
|
||||
| "length"
|
||||
| "title"
|
||||
| "description"
|
||||
| "disableGuests"
|
||||
| "slotInterval"
|
||||
| "minimumBookingNotice"
|
||||
| "beforeEventBuffer"
|
||||
| "afterEventBuffer"
|
||||
| "slug"
|
||||
| "schedulingType"
|
||||
| "requiresConfirmation"
|
||||
| "price"
|
||||
| "currency"
|
||||
| "lockTimeZoneToggleOnBookingPage"
|
||||
| "seatsPerTimeSlot"
|
||||
| "forwardParamsSuccessRedirect"
|
||||
| "successRedirectUrl"
|
||||
| "seatsShowAvailabilityCount"
|
||||
| "isInstantEvent"
|
||||
| "locations"
|
||||
| "bookingFields"
|
||||
| "recurringEvent"
|
||||
| "metadata"
|
||||
| "users"
|
||||
| "scheduleId"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class OutputEventTypesService_2024_06_14 {
|
||||
async getResponseEventType(ownerId: number, databaseEventType: Input) {
|
||||
const {
|
||||
id,
|
||||
length,
|
||||
title,
|
||||
description,
|
||||
disableGuests,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
beforeEventBuffer,
|
||||
afterEventBuffer,
|
||||
slug,
|
||||
schedulingType,
|
||||
requiresConfirmation,
|
||||
price,
|
||||
currency,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
seatsPerTimeSlot,
|
||||
forwardParamsSuccessRedirect,
|
||||
successRedirectUrl,
|
||||
seatsShowAvailabilityCount,
|
||||
isInstantEvent,
|
||||
scheduleId,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
|
||||
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
const users = this.transformUsers(databaseEventType.users);
|
||||
|
||||
return {
|
||||
id,
|
||||
ownerId,
|
||||
lengthInMinutes: length,
|
||||
title,
|
||||
slug,
|
||||
description: description || "",
|
||||
locations,
|
||||
bookingFields,
|
||||
recurringEvent,
|
||||
disableGuests,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
beforeEventBuffer,
|
||||
afterEventBuffer,
|
||||
schedulingType,
|
||||
metadata,
|
||||
requiresConfirmation,
|
||||
price,
|
||||
currency,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
seatsPerTimeSlot,
|
||||
forwardParamsSuccessRedirect,
|
||||
successRedirectUrl,
|
||||
seatsShowAvailabilityCount,
|
||||
isInstantEvent,
|
||||
users,
|
||||
scheduleId,
|
||||
};
|
||||
}
|
||||
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
|
||||
}
|
||||
|
||||
transformBookingFields(inputBookingFields: any) {
|
||||
if (!inputBookingFields) return [];
|
||||
return getResponseEventTypeBookingFields(BookingFieldsSchema.parse(inputBookingFields));
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
return parseRecurringEvent(recurringEvent);
|
||||
}
|
||||
|
||||
transformMetadata(metadata: any) {
|
||||
if (!metadata) return {};
|
||||
return EventTypeMetaDataSchema.parse(metadata);
|
||||
}
|
||||
|
||||
transformUsers(users: User[]) {
|
||||
return users.map((user) => {
|
||||
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
weekStart: user.weekStart,
|
||||
metadata: metadata || {},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
172
calcom/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts
Normal file
172
calcom/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
|
||||
describe("Platform Gcal Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organization: Team;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||
let user: User;
|
||||
let gcalCredentials: Credential;
|
||||
let accessTokenSecret: string;
|
||||
let refreshTokenSecret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id);
|
||||
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
|
||||
accessTokenSecret = tokens.accessToken;
|
||||
refreshTokenSecret = tokens.refreshToken;
|
||||
await app.init();
|
||||
jest
|
||||
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: [CLIENT_REDIRECT_URI],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(oAuthClient).toBeDefined();
|
||||
expect(accessTokenSecret).toBeDefined();
|
||||
expect(refreshTokenSecret).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer invalid_access_token`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
const data = response.body.data;
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(301);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/save: without access token`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(301);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/save: without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
)
|
||||
.expect(301);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/check with access token but without origin`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/check without access token`, async () => {
|
||||
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
|
||||
gcalCredentials = await credentialsRepositoryFixture.create(
|
||||
"google_calendar",
|
||||
{},
|
||||
user.id,
|
||||
"google-calendar"
|
||||
);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/check`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await credentialsRepositoryFixture.delete(gcalCredentials.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
119
calcom/apps/api/v2/src/ee/gcal/gcal.controller.ts
Normal file
119
calcom/apps/api/v2/src/ee/gcal/gcal.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output";
|
||||
import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output";
|
||||
import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GCalService } from "@/modules/apps/services/gcal.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Query,
|
||||
Redirect,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
const CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
];
|
||||
|
||||
// Controller for the GCalConnect Atom
|
||||
@Controller({
|
||||
path: "/v2/gcal",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Google Calendar")
|
||||
export class GcalController {
|
||||
private readonly logger = new Logger("Platform Gcal Provider");
|
||||
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly gcalService: GCalService,
|
||||
private readonly calendarsService: CalendarsService
|
||||
) {}
|
||||
|
||||
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||
|
||||
@Get("/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async redirect(
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Req() req: Request
|
||||
): Promise<GcalAuthUrlOutput> {
|
||||
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}`,
|
||||
});
|
||||
return { status: SUCCESS_STATUS, data: { authUrl } };
|
||||
}
|
||||
|
||||
@Get("/oauth/save")
|
||||
@Redirect(undefined, 301)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
|
||||
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
|
||||
url.searchParams.append("code", code);
|
||||
url.searchParams.append("state", state);
|
||||
return { url: url.href };
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@Permissions([APPS_READ])
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
const googleCalendar = connectedCalendars.find(
|
||||
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
if (!googleCalendar) {
|
||||
throw new UnauthorizedException("Google Calendar not connected.");
|
||||
}
|
||||
if (googleCalendar.error?.message) {
|
||||
throw new UnauthorizedException(googleCalendar.error?.message);
|
||||
}
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
29
calcom/apps/api/v2/src/ee/gcal/gcal.module.ts
Normal file
29
calcom/apps/api/v2/src/ee/gcal/gcal.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalController } from "@/ee/gcal/gcal.controller";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { GCalService } from "@/modules/apps/services/gcal.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TokensModule, OAuthClientModule],
|
||||
providers: [
|
||||
AppsRepository,
|
||||
ConfigService,
|
||||
CredentialsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
GCalService,
|
||||
CalendarsService,
|
||||
UsersRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
controllers: [GcalController],
|
||||
})
|
||||
export class GcalModule {}
|
||||
20
calcom/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts
Normal file
20
calcom/apps/api/v2/src/ee/gcal/outputs/auth-url.output.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class AuthUrlData {
|
||||
@IsString()
|
||||
authUrl!: string;
|
||||
}
|
||||
|
||||
export class GcalAuthUrlOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Type(() => AuthUrlData)
|
||||
@ValidateNested()
|
||||
data!: AuthUrlData;
|
||||
}
|
||||
10
calcom/apps/api/v2/src/ee/gcal/outputs/check.output.ts
Normal file
10
calcom/apps/api/v2/src/ee/gcal/outputs/check.output.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GcalCheckOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class GcalSaveRedirectOutput {
|
||||
@IsString()
|
||||
url!: string;
|
||||
}
|
||||
144
calcom/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts
Normal file
144
calcom/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UserResponse } from "@calcom/platform-types";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Me Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "me-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get user associated with access token", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
expect(responseBody.data.timeZone).toEqual(user.timeZone);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user associated with access token", async () => {
|
||||
const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" };
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch("/v2/me")
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
expect(responseBody.data.id).toEqual(user.id);
|
||||
expect(responseBody.data.email).toEqual(user.email);
|
||||
expect(responseBody.data.timeFormat).toEqual(user.timeFormat);
|
||||
expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId);
|
||||
expect(responseBody.data.weekStart).toEqual(user.weekStart);
|
||||
expect(responseBody.data.timeZone).toEqual(body.timeZone);
|
||||
|
||||
if (user.defaultScheduleId) {
|
||||
const defaultSchedule = await schedulesRepositoryFixture.getById(user.defaultScheduleId);
|
||||
expect(defaultSchedule?.timeZone).toEqual(body.timeZone);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user associated with access token given badly formatted timezone", async () => {
|
||||
const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" };
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch("/v2/me")
|
||||
.send(bodyWithBadlyFormattedTimeZone)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
expect(responseBody.data.timeZone).toEqual("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid timezone", async () => {
|
||||
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid time format", async () => {
|
||||
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 as any };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
|
||||
});
|
||||
|
||||
it("should not update user associated with access token given invalid week start", async () => {
|
||||
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" as any };
|
||||
|
||||
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
60
calcom/apps/api/v2/src/ee/me/me.controller.ts
Normal file
60
calcom/apps/api/v2/src/ee/me/me.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
|
||||
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/me",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Me")
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly schedulesService: SchedulesService_2024_04_15
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([PROFILE_READ])
|
||||
async getMe(@GetUser() user: UserWithProfile): Promise<GetMeOutput> {
|
||||
const me = userSchemaResponse.parse(user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: me,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/")
|
||||
@Permissions([PROFILE_WRITE])
|
||||
async updateMe(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateManagedUserInput
|
||||
): Promise<UpdateMeOutput> {
|
||||
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
|
||||
if (bodySchedule.timeZone && user.defaultScheduleId) {
|
||||
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
|
||||
timeZone: bodySchedule.timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
const me = userSchemaResponse.parse(updatedUser);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: me,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
calcom/apps/api/v2/src/ee/me/me.module.ts
Normal file
11
calcom/apps/api/v2/src/ee/me/me.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MeController } from "@/ee/me/me.controller";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
||||
controllers: [MeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
20
calcom/apps/api/v2/src/ee/me/outputs/get-me.output.ts
Normal file
20
calcom/apps/api/v2/src/ee/me/outputs/get-me.output.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeOutput } from "@/ee/me/outputs/me.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetMeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: MeOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => MeOutput)
|
||||
data!: MeOutput;
|
||||
}
|
||||
25
calcom/apps/api/v2/src/ee/me/outputs/me.output.ts
Normal file
25
calcom/apps/api/v2/src/ee/me/outputs/me.output.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsInt, IsEmail, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class MeOutput {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsInt()
|
||||
timeFormat!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
defaultScheduleId!: number | null;
|
||||
|
||||
@IsString()
|
||||
weekStart!: string;
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
}
|
||||
20
calcom/apps/api/v2/src/ee/me/outputs/update-me.output.ts
Normal file
20
calcom/apps/api/v2/src/ee/me/outputs/update-me.output.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MeOutput } from "@/ee/me/outputs/me.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateMeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: MeOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => MeOutput)
|
||||
data!: MeOutput;
|
||||
}
|
||||
33
calcom/apps/api/v2/src/ee/platform-endpoints-module.ts
Normal file
33
calcom/apps/api/v2/src/ee/platform-endpoints-module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { BookingsModule } from "@/ee/bookings/bookings.module";
|
||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||
import { MeModule } from "@/ee/me/me.module";
|
||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SlotsModule } from "@/modules/slots/slots.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GcalModule,
|
||||
ProviderModule,
|
||||
SchedulesModule_2024_04_15,
|
||||
SchedulesModule_2024_06_11,
|
||||
MeModule,
|
||||
EventTypesModule_2024_04_15,
|
||||
EventTypesModule_2024_06_14,
|
||||
CalendarsModule,
|
||||
BookingsModule,
|
||||
SlotsModule,
|
||||
],
|
||||
})
|
||||
export class PlatformEndpointsModule implements NestModule {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
configure(_consumer: MiddlewareConsumer) {
|
||||
// TODO: apply ratelimits
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class ProviderVerifyAccessTokenOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class ProviderVerifyClientOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
65
calcom/apps/api/v2/src/ee/provider/provider.controller.ts
Normal file
65
calcom/apps/api/v2/src/ee/provider/provider.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
|
||||
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
|
||||
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 { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/provider",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Cal provider")
|
||||
export class CalProviderController {
|
||||
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
|
||||
|
||||
@Get("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyClientId(@Param("clientId") clientId: string): Promise<ProviderVerifyClientOutput> {
|
||||
if (!clientId) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId);
|
||||
|
||||
if (!oAuthClient) throw new UnauthorizedException();
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:clientId/access-token")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async verifyAccessToken(
|
||||
@Param("clientId") clientId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ProviderVerifyAccessTokenOutput> {
|
||||
if (!clientId) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
calcom/apps/api/v2/src/ee/provider/provider.module.ts
Normal file
13
calcom/apps/api/v2/src/ee/provider/provider.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CalProviderController } from "@/ee/provider/provider.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TokensModule, OAuthClientModule],
|
||||
providers: [CredentialsRepository],
|
||||
controllers: [CalProviderController],
|
||||
})
|
||||
export class ProviderModule {}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
|
||||
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
describe("Schedules Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "schedules-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
let createdSchedule: CreateScheduleOutput_2024_04_15["data"];
|
||||
const defaultAvailabilityDays = [1, 2, 3, 4, 5];
|
||||
const defaultAvailabilityStartTime = "1970-01-01T09:00:00.000Z";
|
||||
const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not create an invalid schedule", async () => {
|
||||
const scheduleName = "schedule-name";
|
||||
const scheduleTimeZone = "Europe/Rome";
|
||||
const isDefault = true;
|
||||
|
||||
const body = {
|
||||
name: scheduleName,
|
||||
timeZone: scheduleTimeZone,
|
||||
isDefault,
|
||||
availabilities: [
|
||||
{
|
||||
days: ["Monday"],
|
||||
endTime: "11:15",
|
||||
startTime: "10:00",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(400)
|
||||
.then(async (response) => {
|
||||
expect(response.body.status).toEqual("error");
|
||||
expect(response.body.error.message).toEqual(
|
||||
"Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: 11:15"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a default schedule", async () => {
|
||||
const scheduleName = "schedule-name";
|
||||
const scheduleTimeZone = "Europe/Rome";
|
||||
const isDefault = true;
|
||||
|
||||
const body: CreateScheduleInput_2024_04_15 = {
|
||||
name: scheduleName,
|
||||
timeZone: scheduleTimeZone,
|
||||
isDefault,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.isDefault).toEqual(isDefault);
|
||||
expect(responseData.data.timeZone).toEqual(scheduleTimeZone);
|
||||
expect(responseData.data.name).toEqual(scheduleName);
|
||||
|
||||
const schedule = responseData.data.schedule;
|
||||
expect(schedule).toBeDefined();
|
||||
expect(schedule.length).toEqual(1);
|
||||
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
|
||||
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
|
||||
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
|
||||
|
||||
const scheduleUser = schedule?.[0].userId
|
||||
? await userRepositoryFixture.get(schedule?.[0].userId)
|
||||
: null;
|
||||
expect(scheduleUser?.defaultScheduleId).toEqual(responseData.data.id);
|
||||
createdSchedule = responseData.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should get default schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/api/v2/schedules/default")
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseData: CreateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.id).toEqual(createdSchedule.id);
|
||||
expect(responseData.data.schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId);
|
||||
|
||||
const schedule = responseData.data.schedule;
|
||||
expect(schedule).toBeDefined();
|
||||
expect(schedule.length).toEqual(1);
|
||||
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
|
||||
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
|
||||
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/schedules`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseData: GetSchedulesOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data?.[0].id).toEqual(createdSchedule.id);
|
||||
expect(responseData.data?.[0].schedule?.[0].userId).toEqual(createdSchedule.schedule[0].userId);
|
||||
|
||||
const schedule = responseData.data?.[0].schedule;
|
||||
expect(schedule).toBeDefined();
|
||||
expect(schedule.length).toEqual(1);
|
||||
expect(schedule?.[0]?.days).toEqual(defaultAvailabilityDays);
|
||||
expect(schedule?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
|
||||
expect(schedule?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update schedule name", async () => {
|
||||
const newScheduleName = "new-schedule-name";
|
||||
|
||||
const body: UpdateScheduleInput_2024_04_15 = {
|
||||
name: newScheduleName,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_04_15 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseData.data).toBeDefined();
|
||||
expect(responseData.data.schedule.name).toEqual(newScheduleName);
|
||||
expect(responseData.data.schedule.id).toEqual(createdSchedule.id);
|
||||
expect(responseData.data.schedule.userId).toEqual(createdSchedule.schedule[0].userId);
|
||||
|
||||
const availability = responseData.data.schedule.availability;
|
||||
expect(availability).toBeDefined();
|
||||
expect(availability?.length).toEqual(1);
|
||||
expect(availability?.[0]?.days).toEqual(defaultAvailabilityDays);
|
||||
expect(availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime);
|
||||
expect(availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime);
|
||||
|
||||
createdSchedule.name = newScheduleName;
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
try {
|
||||
await scheduleRepositoryFixture.deleteById(createdSchedule.id);
|
||||
} catch (e) {}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { CreateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/create-schedule.output";
|
||||
import { DeleteScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/delete-schedule.output";
|
||||
import { GetDefaultScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-default-schedule.output";
|
||||
import { GetScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedule.output";
|
||||
import { GetSchedulesOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/get-schedules.output";
|
||||
import { UpdateScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/update-schedule.output";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/schedules",
|
||||
version: VERSION_2024_04_15_VALUE,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Schedules")
|
||||
export class SchedulesController_2024_04_15 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
|
||||
|
||||
@Post("/")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/default")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Returns the default schedule",
|
||||
type: GetDefaultScheduleOutput_2024_04_15,
|
||||
})
|
||||
async getDefaultSchedule(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
const scheduleFormatted = schedule
|
||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
||||
: null;
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedulesFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
// note(Lauris): currently this endpoint is atoms only
|
||||
@Patch("/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async updateSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: UpdateScheduleInput_2024_04_15,
|
||||
@Param("scheduleId") scheduleId: string
|
||||
): Promise<UpdateScheduleOutput_2024_04_15> {
|
||||
const updatedSchedule = await this.schedulesService.updateUserSchedule(
|
||||
user,
|
||||
Number(scheduleId),
|
||||
bodySchedule
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
async deleteSchedule(
|
||||
@GetUser("id") userId: number,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<DeleteScheduleOutput_2024_04_15> {
|
||||
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform, TransformFnParams } from "class-transformer";
|
||||
import { IsArray, IsDate, IsNumber } from "class-validator";
|
||||
|
||||
export class CreateAvailabilityInput_2024_04_15 {
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ApiProperty({ example: [1, 2] })
|
||||
days!: number[];
|
||||
|
||||
@IsDate()
|
||||
@Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key))
|
||||
startTime!: Date;
|
||||
|
||||
@IsDate()
|
||||
@Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key))
|
||||
endTime!: Date;
|
||||
}
|
||||
|
||||
function transformStringToDate(value: string, key: string): Date {
|
||||
if (!value) {
|
||||
throw new BadRequestException(
|
||||
`Missing ${key}. Expected value is in ISO8061 format e.g. 2025-0412T13:17:56.324Z`
|
||||
);
|
||||
}
|
||||
|
||||
const dateTimeParts = value.split("T");
|
||||
if (dateTimeParts.length !== 2) {
|
||||
throw new BadRequestException(
|
||||
`Invalid datestring format. Expected format(ISO8061): 2025-04-12T13:17:56.324Z. Received: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
|
||||
const parts = timePart.split(":");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new BadRequestException(
|
||||
`Invalid time format. Expected format(ISO8061): 2025-0412T13:17:56.324Z. Received: ${value}`
|
||||
);
|
||||
}
|
||||
const [hours, minutes, seconds] = parts.map(Number);
|
||||
|
||||
if (hours < 0 || hours > 23) {
|
||||
throw new BadRequestException(`Invalid ${key} hours. Expected value between 0 and 23`);
|
||||
}
|
||||
|
||||
if (minutes < 0 || minutes > 59) {
|
||||
throw new BadRequestException(`Invalid ${key} minutes. Expected value between 0 and 59`);
|
||||
}
|
||||
|
||||
if (seconds < 0 || seconds > 59) {
|
||||
throw new BadRequestException(`Invalid ${key} seconds. Expected value between 0 and 59`);
|
||||
}
|
||||
|
||||
return new Date(new Date().setUTCHours(hours, minutes, seconds, 0));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export class CreateScheduleInput_2024_04_15 {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsTimeZone()
|
||||
timeZone!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateAvailabilityInput_2024_04_15)
|
||||
@IsOptional()
|
||||
availabilities?: CreateAvailabilityInput_2024_04_15[];
|
||||
|
||||
@IsBoolean()
|
||||
isDefault!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ScheduleOutput)
|
||||
data!: ScheduleOutput;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetDefaultScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ScheduleOutput)
|
||||
data!: ScheduleOutput | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ScheduleOutput)
|
||||
data!: ScheduleOutput;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetSchedulesOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: ScheduleOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScheduleOutput)
|
||||
@IsArray()
|
||||
data!: ScheduleOutput[];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
|
||||
class EventTypeModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eventName?: string | null;
|
||||
}
|
||||
|
||||
class AvailabilityModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
userId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
scheduleId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
eventTypeId?: number | null;
|
||||
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
days!: number[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Date)
|
||||
@IsString() // Assuming date is serialized/deserialized appropriately
|
||||
startTime?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Date)
|
||||
@IsString() // Assuming date is serialized/deserialized appropriately
|
||||
endTime?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Date)
|
||||
@IsString() // Assuming date is serialized/deserialized appropriately
|
||||
date?: Date | null;
|
||||
}
|
||||
|
||||
class ScheduleModel_2024_04_15 {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
userId!: number;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timeZone?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeModel_2024_04_15)
|
||||
@IsArray()
|
||||
eventType?: EventTypeModel_2024_04_15[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AvailabilityModel_2024_04_15)
|
||||
@IsArray()
|
||||
availability?: AvailabilityModel_2024_04_15[];
|
||||
}
|
||||
|
||||
export class UpdatedScheduleOutput_2024_04_15 {
|
||||
@ValidateNested()
|
||||
@Type(() => ScheduleModel_2024_04_15)
|
||||
schedule!: ScheduleModel_2024_04_15;
|
||||
|
||||
@IsBoolean()
|
||||
isDefault!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timeZone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
prevDefaultId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
currentDefaultId?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
class AvailabilityModel {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
userId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
eventTypeId?: number | null;
|
||||
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
days!: number[];
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
startTime!: Date;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
endTime!: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
date?: Date | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
scheduleId?: number | null;
|
||||
}
|
||||
|
||||
class WorkingHours {
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
days!: number[];
|
||||
|
||||
@IsInt()
|
||||
startTime!: number;
|
||||
|
||||
@IsInt()
|
||||
endTime!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
userId?: number | null;
|
||||
}
|
||||
|
||||
class TimeRange {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
userId?: number | null;
|
||||
|
||||
@IsDate()
|
||||
start!: Date;
|
||||
|
||||
@IsDate()
|
||||
end!: Date;
|
||||
}
|
||||
|
||||
export class ScheduleOutput {
|
||||
@IsInt()
|
||||
id!: number;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsBoolean()
|
||||
isManaged!: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkingHours)
|
||||
workingHours!: WorkingHours[];
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AvailabilityModel)
|
||||
@IsArray()
|
||||
schedule!: AvailabilityModel[];
|
||||
|
||||
availability!: TimeRange[][];
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
// note(Lauris) it should be
|
||||
// dateOverrides!: { ranges: TimeRange[] }[];
|
||||
// but docs aren't generating correctly it results in array of strings
|
||||
dateOverrides!: unknown[];
|
||||
|
||||
@IsBoolean()
|
||||
isDefault!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isLastSchedule!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
readOnly!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { UpdatedScheduleOutput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule-updated.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class UpdateScheduleOutput_2024_04_15 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: UpdatedScheduleOutput_2024_04_15,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdatedScheduleOutput_2024_04_15)
|
||||
data!: UpdatedScheduleOutput_2024_04_15;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SchedulesController_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/controllers/schedules.controller";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
providers: [SchedulesRepository_2024_04_15, SchedulesService_2024_04_15],
|
||||
controllers: [SchedulesController_2024_04_15],
|
||||
exports: [SchedulesService_2024_04_15, SchedulesRepository_2024_04_15],
|
||||
})
|
||||
export class SchedulesModule_2024_04_15 {}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesRepository_2024_04_15 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async createScheduleWithAvailabilities(
|
||||
userId: number,
|
||||
schedule: CreateScheduleInput_2024_04_15,
|
||||
availabilities: CreateAvailabilityInput_2024_04_15[]
|
||||
) {
|
||||
const createScheduleData: Prisma.ScheduleCreateInput = {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: schedule.name,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
|
||||
if (availabilities.length > 0) {
|
||||
createScheduleData.availability = {
|
||||
createMany: {
|
||||
data: availabilities.map((availability) => {
|
||||
return {
|
||||
days: availability.days,
|
||||
startTime: availability.startTime,
|
||||
endTime: availability.endTime,
|
||||
userId,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createdSchedule = await this.dbWrite.prisma.schedule.create({
|
||||
data: {
|
||||
...createScheduleData,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return createdSchedule;
|
||||
}
|
||||
|
||||
async getScheduleById(scheduleId: number) {
|
||||
const schedule = await this.dbRead.prisma.schedule.findUnique({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async getSchedulesByUserId(userId: number) {
|
||||
const schedules = await this.dbRead.prisma.schedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
async deleteScheduleById(scheduleId: number) {
|
||||
return this.dbWrite.prisma.schedule.delete({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSchedulesCount(userId: number) {
|
||||
return this.dbRead.prisma.schedule.count({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
const schedule = {
|
||||
isDefault: true,
|
||||
name: "Default schedule",
|
||||
timeZone,
|
||||
};
|
||||
|
||||
return this.createUserSchedule(userId, schedule);
|
||||
}
|
||||
|
||||
async createUserSchedule(userId: number, schedule: CreateScheduleInput_2024_04_15) {
|
||||
const availabilities = schedule.availabilities?.length
|
||||
? schedule.availabilities
|
||||
: [this.getDefaultAvailabilityInput()];
|
||||
|
||||
const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities(
|
||||
userId,
|
||||
schedule,
|
||||
availabilities
|
||||
);
|
||||
|
||||
if (schedule.isDefault) {
|
||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||
}
|
||||
|
||||
return createdSchedule;
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(userId, existingSchedule);
|
||||
|
||||
return existingSchedule;
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
user: UserWithProfile,
|
||||
scheduleId: number,
|
||||
bodySchedule: UpdateScheduleInput_2024_04_15
|
||||
) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||
|
||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
||||
|
||||
if (!bodySchedule.schedule) {
|
||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
||||
// the schedule is sent and then passed to the update handler. Notably, availability is passed too
|
||||
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
||||
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||
// schedule.
|
||||
bodySchedule.schedule = scheduleFormatted.availability;
|
||||
}
|
||||
|
||||
return updateScheduleHandler({
|
||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
||||
ctx: { user },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
}
|
||||
|
||||
this.checkUserOwnsSchedule(userId, existingSchedule);
|
||||
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
|
||||
}
|
||||
|
||||
async formatSchedulesForAtom(
|
||||
user: User,
|
||||
schedules: ScheduleWithAvailabilities[]
|
||||
): Promise<ScheduleOutput[]> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return Promise.all(
|
||||
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
|
||||
);
|
||||
}
|
||||
|
||||
async transformScheduleForAtom(
|
||||
schedule: ScheduleWithAvailabilities,
|
||||
userSchedulesCount: number,
|
||||
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
|
||||
): Promise<ScheduleOutput> {
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
const defaultSchedule = await this.getUserScheduleDefault(user.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== user.id,
|
||||
workingHours: transformWorkingHoursForClient(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForClient(schedule),
|
||||
timeZone,
|
||||
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
|
||||
isDefault: defaultSchedule?.id === schedule.id,
|
||||
isLastSchedule: userSchedulesCount <= 1,
|
||||
readOnly: schedule.userId !== user.id,
|
||||
};
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultAvailabilityInput(): CreateAvailabilityInput_2024_04_15 {
|
||||
const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0));
|
||||
const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0));
|
||||
|
||||
return {
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
CreateScheduleOutput_2024_06_11,
|
||||
GetScheduleOutput_2024_06_11,
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
ScheduleOutput_2024_06_11,
|
||||
UpdateScheduleOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
import { UpdateScheduleInput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
describe("Schedules Endpoints", () => {
|
||||
describe("User Authentication", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
const userEmail = "schedules-controller-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
|
||||
name: "work",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const defaultAvailability: CreateScheduleInput_2024_06_11["availability"] = [
|
||||
{
|
||||
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
startTime: "09:00",
|
||||
endTime: "17:00",
|
||||
},
|
||||
];
|
||||
|
||||
let createdSchedule: CreateScheduleOutput_2024_06_11["data"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a default schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/schedules")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(createScheduleInput)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
createdSchedule = response.body.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
|
||||
|
||||
const scheduleOwner = createdSchedule.ownerId
|
||||
? await userRepositoryFixture.get(createdSchedule.ownerId)
|
||||
: null;
|
||||
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
function outputScheduleMatchesExpected(
|
||||
outputSchedule: ScheduleOutput_2024_06_11 | null,
|
||||
expected: CreateScheduleInput_2024_06_11 & {
|
||||
availability: CreateScheduleInput_2024_06_11["availability"];
|
||||
} & {
|
||||
overrides: CreateScheduleInput_2024_06_11["overrides"];
|
||||
},
|
||||
expectedAvailabilityLength: number
|
||||
) {
|
||||
expect(outputSchedule).toBeTruthy();
|
||||
expect(outputSchedule?.name).toEqual(expected.name);
|
||||
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
|
||||
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
|
||||
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
|
||||
|
||||
const outputScheduleAvailability = outputSchedule?.availability[0];
|
||||
expect(outputScheduleAvailability).toBeDefined();
|
||||
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
|
||||
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
|
||||
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
|
||||
|
||||
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
|
||||
}
|
||||
|
||||
it("should get default schedule", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/api/v2/schedules/default")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const outputSchedule = responseBody.data;
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get schedules", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/schedules`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const outputSchedule = responseBody.data[0];
|
||||
|
||||
const expectedSchedule = {
|
||||
...createScheduleInput,
|
||||
availability: defaultAvailability,
|
||||
overrides: [],
|
||||
};
|
||||
outputScheduleMatchesExpected(outputSchedule, expectedSchedule, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update schedule name", async () => {
|
||||
const newScheduleName = "updated-schedule-name";
|
||||
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
name: newScheduleName,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should add overrides", async () => {
|
||||
const overrides = [
|
||||
{
|
||||
date: "2026-05-05",
|
||||
startTime: "10:00",
|
||||
endTime: "12:00",
|
||||
},
|
||||
];
|
||||
|
||||
const body: UpdateScheduleInput_2024_06_11 = {
|
||||
overrides,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/schedules/${createdSchedule.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response: any) => {
|
||||
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
|
||||
expect(responseData.status).toEqual(SUCCESS_STATUS);
|
||||
const responseSchedule = responseData.data;
|
||||
|
||||
const expectedSchedule = { ...createdSchedule, overrides };
|
||||
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
|
||||
|
||||
createdSchedule = responseSchedule;
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete schedule", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
try {
|
||||
await scheduleRepositoryFixture.deleteById(createdSchedule.id);
|
||||
} catch (e) {}
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user