Compare commits

...

1 Commits

Author SHA1 Message Date
exception eefa6d16bd
chore: NestJS platform api 2023-11-02 19:23:26 -03:00
32 changed files with 3856 additions and 430 deletions

View File

@ -0,0 +1,25 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: [
"plugin:@typescript-eslint/recommended",
// 'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js"],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
},
};

38
apps/platform/api-nest/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# ENV
.env*

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -0,0 +1,77 @@
{
"name": "cal-platform-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@calcom/prisma": "*",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"dotenv": "^16.3.1",
"nest-winston": "^1.9.4",
"nestjs-zod": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,10 @@
import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
@Controller()
export class AppController {
@Get("health")
@Version(VERSION_NEUTRAL)
getHealth(): "OK" {
return "OK";
}
}

View File

@ -0,0 +1,31 @@
import appConfig from "@/config/app";
import { AuthModule } from "@/modules/auth/auth.module";
import { EndpointsModule } from "@/modules/endpoints-module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_PIPE } from "@nestjs/core";
import { ZodValidationPipe } from "nestjs-zod";
import { AppController } from "./app.controller";
@Module({
imports: [
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
load: [appConfig],
}),
PrismaModule,
EndpointsModule,
AuthModule,
],
controllers: [AppController],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,24 @@
import { RequestMethod, VersioningType } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
app.enableShutdownHooks();
app.enableVersioning({
type: VersioningType.URI,
prefix: "v",
defaultVersion: "1",
});
app.enableCors({
origin: "*",
methods: ["GET", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"],
maxAge: 86_400,
});
app.setGlobalPrefix("api", {
exclude: [{ path: "health", method: RequestMethod.GET }],
});
return app;
};

View File

@ -0,0 +1,18 @@
import { AppConfig } from "./type";
const loadConfig = (): AppConfig => {
return {
env: {
type: (process.env.NODE_ENV as "production" | "development") ?? "development",
},
api: {
port: Number(process.env.API_PORT ?? 5555),
},
db: {
readUrl: process.env.DATABASE_READ_URL ?? "",
writeUrl: process.env.DATABSE_WRITE_URL ?? "",
},
};
};
export default loadConfig;

View File

@ -0,0 +1,12 @@
export type AppConfig = {
env: {
type: "production" | "development";
};
api: {
port: number;
};
db: {
readUrl: string;
writeUrl: string;
};
};

View File

@ -0,0 +1,3 @@
import { createHash } from "crypto";
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");

View File

@ -0,0 +1,48 @@
import type { LoggerOptions } from "winston";
import { format, transports } from "winston";
const formattedTimestamp = format.timestamp({
format: "YYYY-MM-DD HH:mm:ss.SSS",
});
const colorizer = format.colorize({
colors: {
fatal: "red",
error: "red",
warn: "yellow",
info: "blue",
debug: "white",
trace: "grey",
},
});
const WINSTON_DEV_FORMAT = format.combine(
format.errors({ stack: true }),
colorizer,
formattedTimestamp,
format.simple()
);
const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json());
export const loggerConfig = (): LoggerOptions => {
const isProduction = process.env.NODE_ENV === "production";
return {
levels: {
fatal: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
trace: 5,
},
level: process.env.LOG_LEVEL ?? "info",
format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT,
transports: [new transports.Console()],
exceptionHandlers: [new transports.Console()],
rejectionHandlers: [new transports.Console()],
defaultMeta: {
service: "cal-platform-api",
},
};
};

View File

@ -0,0 +1,35 @@
import { AppConfig } from "@/config/type";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import "dotenv/config";
import { WinstonModule } from "nest-winston";
import { bootstrap } from "./app";
import { AppModule } from "./app.module";
import { loggerConfig } from "./lib/logger";
const run = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
});
const logger = new Logger("App");
try {
bootstrap(app);
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
logger.error("Application crashed", {
error,
});
}
};
run().catch((error: Error) => {
console.error("Failed to start Cal Platform API", { error: error.stack });
process.exit(1);
});

View File

@ -0,0 +1,8 @@
import { ApiKeyService } from "@/modules/api-key/api-key.service";
import { Module } from "@nestjs/common";
@Module({
providers: [ApiKeyService],
exports: [ApiKeyService],
})
export class ApiKeyModule {}

View File

@ -0,0 +1,40 @@
import { hashAPIKey } from "@/lib/api-key";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Response } from "@/types";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Request } from "express";
type ApiKeyInfo = {
hashedKey: string;
id: string;
userId: number;
teamId: number | null;
};
@Injectable()
export class ApiKeyService {
constructor(private readonly dbRead: PrismaReadService) {}
private setResponseApiKey = (response: Response, key: ApiKeyInfo) => {
response.locals.apiKey = key;
};
async retrieveApiKey(request: Request, response?: Response) {
const apiKey = request.get("Authorization")?.replace("Bearer ", "");
if (!apiKey) throw new BadRequestException("Invalid API Key");
const hashedKey = hashAPIKey(apiKey.replace("cal_", ""));
const apiKeyResult = await this.dbRead.prisma.apiKey.findUnique({
where: {
hashedKey,
},
});
if (response && apiKeyResult) {
void this.setResponseApiKey(response, apiKeyResult);
}
return apiKeyResult;
}
}

View File

@ -0,0 +1,34 @@
import { ApiKeyService } from "@/modules/api-key/api-key.service";
import { UserRepository } from "@/modules/repositories/user/user-repository.service";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Request } from "express";
class BaseStrategy {
success!: (user: unknown) => void;
error!: (error: Error) => void;
}
@Injectable()
export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key") {
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly userRepository: UserRepository
) {
super();
}
async authenticate(req: Request) {
const apiKey = await this.apiKeyService.retrieveApiKey(req);
if (!apiKey) {
throw new UnauthorizedException();
}
if (apiKey.expiresAt && new Date() > apiKey.expiresAt) {
throw new Error("This apiKey is expired");
}
const user = await this.userRepository.findById(apiKey.userId);
this.success(user);
}
}

View File

@ -0,0 +1,11 @@
import { ApiKeyModule } from "@/modules/api-key/api-key.module";
import { ApiKeyAuthStrategy } from "@/modules/auth/auth-api-key.strategy";
import { UserModule } from "@/modules/repositories/user/user-repository.module";
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
@Module({
imports: [PassportModule, ApiKeyModule, UserModule],
providers: [ApiKeyAuthStrategy],
})
export class AuthModule {}

View File

@ -0,0 +1,43 @@
import { CreateBookingDto } from "@/modules/booking/dtos/create-booking";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { BookingRepository } from "@/modules/repositories/booking/booking-repository.service";
import { type Response } from "@/types";
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Logger,
Post,
Res,
UseGuards,
VERSION_NEUTRAL,
Version,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Controller("booking")
export class BookingController {
private readonly logger = new Logger("BookingController");
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly bookingRepository: BookingRepository
) {}
@Post("/")
@Version(VERSION_NEUTRAL)
@UseGuards(AuthGuard("api-key"))
@HttpCode(HttpStatus.CREATED)
async createBooking(@Res({ passthrough: true }) res: Response, @Body() body: CreateBookingDto) {
const userId = res.locals.apiKey?.userId;
if (!userId) throw new BadRequestException("Invalid API Key User");
this.logger.log("Created Booking with data " + body);
return this.bookingRepository.createBooking(userId, body);
}
}

View File

@ -0,0 +1,10 @@
import { BookingController } from "@/modules/booking/booking.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { BookingRepositoryModule } from "@/modules/repositories/booking/booking-repository.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, BookingRepositoryModule],
controllers: [BookingController],
})
export class BookingModule {}

View File

@ -0,0 +1,10 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const CreateBookingSchema = z.object({
name: z.string(),
email: z.string(),
timezone: z.string(),
});
export class CreateBookingDto extends createZodDto(CreateBookingSchema) {}

View File

@ -0,0 +1,12 @@
import { BookingModule } from "@/modules/booking/booking.module";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
@Module({
imports: [BookingModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(_consumer: MiddlewareConsumer) {
// TODO: apply ratelimits
}
}

View File

@ -0,0 +1,25 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient, customPrisma } from "@calcom/prisma";
@Injectable()
export class PrismaReadService implements OnModuleInit {
public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true });
this.prisma = customPrisma({
datasources: {
db: {
url: dbUrl,
},
},
});
}
async onModuleInit() {
this.prisma.$connect();
}
}

View File

@ -0,0 +1,25 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient, customPrisma } from "@calcom/prisma";
@Injectable()
export class PrismaWriteService implements OnModuleInit {
public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true });
this.prisma = customPrisma({
datasources: {
db: {
url: dbUrl,
},
},
});
}
async onModuleInit() {
this.prisma.$connect();
}
}

View File

@ -0,0 +1,9 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Module } from "@nestjs/common";
@Module({
providers: [PrismaReadService, PrismaWriteService],
exports: [PrismaReadService, PrismaWriteService],
})
export class PrismaModule {}

View File

@ -0,0 +1,8 @@
import { BookingRepository } from "@/modules/repositories/booking/booking-repository.service";
import { Module } from "@nestjs/common";
@Module({
providers: [BookingRepository],
exports: [BookingRepository],
})
export class BookingRepositoryModule {}

View File

@ -0,0 +1,33 @@
import { CreateBookingSchema } from "@/modules/booking/dtos/create-booking";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { z } from "nestjs-zod/z";
@Injectable()
export class BookingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createBooking(userId: number, data: z.infer<typeof CreateBookingSchema>) {
return this.dbWrite.prisma.booking.create({
data: {
user: {
connect: {
id: userId,
},
},
startTime: new Date(),
endTime: new Date(),
title: "Test Event",
uid: "test-event",
attendees: {
create: {
email: data.email,
name: data.name,
timeZone: data.timezone,
},
},
},
});
}
}

View File

@ -0,0 +1,8 @@
import { UserRepository } from "@/modules/repositories/user/user-repository.service";
import { Module } from "@nestjs/common";
@Module({
providers: [UserRepository],
exports: [UserRepository],
})
export class UserModule {}

View File

@ -0,0 +1,15 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class UserRepository {
constructor(private readonly dbRead: PrismaReadService) {}
async findById(userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
id: userId,
},
});
}
}

View File

@ -0,0 +1,12 @@
import type { Response as BaseResponse } from "express";
export interface PlatformApiLocals extends Record<string, unknown> {
apiKey?: {
hashedKey: string;
id: string;
userId: number;
teamId: number | null;
};
}
export type Response = BaseResponse<unknown, PlatformApiLocals>;

View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
"allowJs": true,
"incremental": true,
"lib": ["ES2022"],
"module": "CommonJS",
"target": "ES2021",
"composite": false,
"alwaysStrict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": false,
"inlineSourceMap": false,
"isolatedModules": false,
"moduleResolution": "node",
"noEmitOnError": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"preserveWatchOutput": true,
"noImplicitAny": false,
"noImplicitThis": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"pretty": true,
"resolveJsonModule": true,
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist", ".turbo"],
"include": ["src"]
}

View File

@ -8,7 +8,8 @@
"packages/embeds/*",
"packages/features/*",
"packages/app-store/*",
"packages/app-store/ee/*"
"packages/app-store/ee/*",
"apps/platform/*"
],
"scripts": {
"app-store-cli": "yarn workspace @calcom/app-store-cli",

View File

@ -19,6 +19,7 @@ module.exports = {
"^~/(.*)$",
"^[./]",
],
importOrderParserPlugins: ["typescript", "decorators-legacy"],
importOrderSeparation: true,
plugins: [
"@trivago/prettier-plugin-sort-imports",

3607
yarn.lock

File diff suppressed because it is too large Load Diff