diff --git a/package.json b/package.json index 39edd03..6f4e6fa 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/src/core/app.module.ts b/src/core/app.module.ts index e414bd1..9cb9e2f 100644 --- a/src/core/app.module.ts +++ b/src/core/app.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' +import { AuthModule } from '../modules/auth/auth.module' + import { AppController } from './app.controller' import { AppService } from './app.service' @Module({ - imports: [ConfigModule.forRoot({ isGlobal: true })], + imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule], controllers: [AppController], providers: [AppService] }) diff --git a/src/core/config/cors.config.ts b/src/core/config/cors.config.ts new file mode 100644 index 0000000..f067c04 --- /dev/null +++ b/src/core/config/cors.config.ts @@ -0,0 +1,9 @@ +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface' +import { ConfigService } from '@nestjs/config' + +export function getCorsConfig(configService: ConfigService): CorsOptions { + return { + origin: configService.getOrThrow('HTTP_CORS').split(','), + credentials: true + } +} diff --git a/src/core/config/index.ts b/src/core/config/index.ts new file mode 100644 index 0000000..635ba52 --- /dev/null +++ b/src/core/config/index.ts @@ -0,0 +1,3 @@ +export * from './cors.config' +export * from './swagger.config' +export * from './validation-pipe.config' diff --git a/src/core/config/swagger.config.ts b/src/core/config/swagger.config.ts new file mode 100644 index 0000000..b681442 --- /dev/null +++ b/src/core/config/swagger.config.ts @@ -0,0 +1,8 @@ +import { DocumentBuilder } from '@nestjs/swagger' + +export const swaggerConfig = new DocumentBuilder() + .setTitle('Teacinema API') + .setDescription('API Gateway for Teacinema microservices') + .setVersion('1.0.0') + .addBearerAuth() + .build() diff --git a/src/core/config/validation-pipe.config.ts b/src/core/config/validation-pipe.config.ts new file mode 100644 index 0000000..45e2b33 --- /dev/null +++ b/src/core/config/validation-pipe.config.ts @@ -0,0 +1,6 @@ +import { ValidationPipe } from '@nestjs/common' + +export const validationPipe = new ValidationPipe({ + transform: true, + whitelist: true +}) diff --git a/src/main.ts b/src/main.ts index e31cff1..54e6b28 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import { Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { NestFactory } from '@nestjs/core' -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { SwaggerModule } from '@nestjs/swagger' import { AppModule } from './core/app.module' +import { getCorsConfig, swaggerConfig, validationPipe } from './core/config' async function bootstrap() { const app = await NestFactory.create(AppModule) @@ -11,17 +12,9 @@ async function bootstrap() { const logger = new Logger() const config = app.get(ConfigService) - app.enableCors({ - origin: config.getOrThrow('HTTP_CORS').split(','), - credentials: true - }) + app.useGlobalPipes(validationPipe) - const swaggerConfig = new DocumentBuilder() - .setTitle('Teacinema API') - .setDescription('API Gateway for Teacinema microservices') - .setVersion('1.0.0') - .addBearerAuth() - .build() + app.enableCors(getCorsConfig(config)) const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig) SwaggerModule.setup('docs', app, swaggerDocument, { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..c345856 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common' +import { ApiOperation } from '@nestjs/swagger' + +import { SendOtpRequestDto } from './dto' + +@Controller('auth') +export class AuthController { + @ApiOperation({ + summary: 'Send OTP', + description: 'Send verification code to the user phone or email' + }) + @Post('otp/send') + @HttpCode(HttpStatus.OK) + public sendOtp(@Body() dto: SendOtpRequestDto) { + console.log('data', dto) + + return { ok: true } + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..d928ab5 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; + +@Module({ + controllers: [AuthController] +}) +export class AuthModule {} diff --git a/src/modules/auth/dto/index.ts b/src/modules/auth/dto/index.ts new file mode 100644 index 0000000..66d0236 --- /dev/null +++ b/src/modules/auth/dto/index.ts @@ -0,0 +1 @@ +export * from './rq' diff --git a/src/modules/auth/dto/rq/index.ts b/src/modules/auth/dto/rq/index.ts new file mode 100644 index 0000000..060b2b8 --- /dev/null +++ b/src/modules/auth/dto/rq/index.ts @@ -0,0 +1 @@ +export * from './send-otp.request.dto' diff --git a/src/modules/auth/dto/rq/send-otp.request.dto.ts b/src/modules/auth/dto/rq/send-otp.request.dto.ts new file mode 100644 index 0000000..a231c72 --- /dev/null +++ b/src/modules/auth/dto/rq/send-otp.request.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsEnum, IsString, Validate } from 'class-validator' + +import { IdentifierValidator } from '../../../../shared/validators' + +export class SendOtpRequestDto { + @ApiProperty({ + description: 'User phone or email', + example: '+791234567890' + }) + @Validate(IdentifierValidator) + @IsString() + public identifier: string + + @ApiProperty({ + description: 'Type of identifier', + example: 'phone', + enum: ['phone', 'email'] + }) + @IsEnum(['phone', 'email']) + public type: 'phone' | 'email' +} diff --git a/src/shared/validators/identifier.validator.ts b/src/shared/validators/identifier.validator.ts new file mode 100644 index 0000000..4aca647 --- /dev/null +++ b/src/shared/validators/identifier.validator.ts @@ -0,0 +1,35 @@ +import { + isEmail, + isPhoneNumber, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator' + +import { SendOtpRequestDto } from '../../modules/auth/dto' + +@ValidatorConstraint({ name: 'IdentifierValidator', async: false }) +export class IdentifierValidator implements ValidatorConstraintInterface { + public validate(value: string, args: ValidationArguments) { + const object = args.object as SendOtpRequestDto + if (object.type === 'email') { + return typeof value === 'string' && isEmail(value) + } else if (object.type === 'phone') { + return typeof value === 'string' && isPhoneNumber(value) + } + + return false + } + + public defaultMessage(args: ValidationArguments) { + const object = args.object as SendOtpRequestDto + + if (object.type === 'email') { + return 'identifier must be a valid email' + } else if (object.type === 'phone') { + return 'identifier must be a valid phone number' + } + + return 'Invalid identifier' + } +} diff --git a/src/shared/validators/index.ts b/src/shared/validators/index.ts new file mode 100644 index 0000000..21bad3b --- /dev/null +++ b/src/shared/validators/index.ts @@ -0,0 +1 @@ +export * from './identifier.validator' diff --git a/yarn.lock b/yarn.lock index 4fa934b..70c7e5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1359,6 +1359,11 @@ "@types/methods" "^1.1.4" "@types/superagent" "^8.1.0" +"@types/validator@^13.15.3": + version "13.15.10" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.15.10.tgz#742b77ec34d58554b94a76a14cef30d59e3c16b9" + integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -2112,6 +2117,20 @@ cjs-module-lexer@^2.1.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + +class-validator@^0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.3.tgz#834a4caafa8359aed73d7708badb4cf271be50fe" + integrity sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA== + dependencies: + "@types/validator" "^13.15.3" + libphonenumber-js "^1.11.1" + validator "^13.15.20" + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -3666,6 +3685,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libphonenumber-js@^1.11.1: + version "1.12.34" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz#20ed9da39e930e98eff4b6e24a8d8480303658af" + integrity sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -4953,6 +4977,11 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^2.0.0" +validator@^13.15.20: + version "13.15.26" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.26.tgz#36c3deeab30e97806a658728a155c66fcaa5b944" + integrity sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA== + vary@^1, vary@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"