mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-27 00:54:54 +00:00
Compare commits
18 Commits
feat/uploa
...
feat/tasks
| Author | SHA1 | Date | |
|---|---|---|---|
| 35b434bc3d | |||
| 749ee5457f | |||
| d539073f29 | |||
| 66e1bb0f28 | |||
| 577f91b796 | |||
| 7ed37c30e1 | |||
| c2f63ccc72 | |||
| 970a41c895 | |||
| 3fd29b3905 | |||
| 7f7fef3f89 | |||
| 90ee8023e6 | |||
| c486d558ad | |||
| 85569af770 | |||
| f97bb08c5c | |||
| 6f6e3f7e7b | |||
| 26b2d153fd | |||
| 2577f2dcac | |||
| e4b69a406f |
@ -8,6 +8,12 @@ DB_NAME=
|
||||
MIGRATIONS_RUN=true
|
||||
SWAGGER_API_DOCS_PATH="/api-docs"
|
||||
|
||||
JWT_ACCESS_TOKEN_SECRET=your_access_token_secret
|
||||
JWT_ACCESS_TOKEN_EXPIRY=1d
|
||||
JWT_REFRESH_TOKEN_SECRET=your_refresh_token_secret
|
||||
JWT_REFRESH_TOKEN_EXPIRY=1d
|
||||
USE_MOCK=true
|
||||
|
||||
|
||||
OCI_TENANCY_ID=
|
||||
OCI_USER_ID=
|
||||
|
||||
676
package-lock.json
generated
676
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -34,7 +34,9 @@
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/event-emitter": "^2.1.1",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/microservices": "^10.4.7",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.8",
|
||||
"@nestjs/swagger": "^8.0.5",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
@ -42,33 +44,45 @@
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"amqp-connection-manager": "^4.1.14",
|
||||
"amqplib": "^0.10.4",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"google-libphonenumber": "^3.2.39",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"nestjs-i18n": "^10.4.9",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"oci-common": "^2.99.0",
|
||||
"oci-sdk": "^2.99.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.1",
|
||||
"pino-http": "^10.3.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
"typeorm": "^0.3.20",
|
||||
"typeorm-transactional": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@golevelup/ts-jest": "^0.6.0",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/google-libphonenumber": "^7.4.30",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"eslint": "^8.39.0",
|
||||
|
||||
@ -4,13 +4,22 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { addTransactionalDataSource } from 'typeorm-transactional';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { LookupModule } from './common/modules/lookup/lookup.module';
|
||||
import { OtpModule } from './common/modules/otp/otp.module';
|
||||
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
||||
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
|
||||
import { buildI18nOptions } from './core/module-options/i18n-options';
|
||||
import { buildValidationPipe } from './core/pipes';
|
||||
import { CustomerModule } from './customer/customer.module';
|
||||
import { migrations } from './db';
|
||||
import { DocumentModule } from './document/document.module';
|
||||
import { GuardianModule } from './guardian/guardian.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { JuniorModule } from './junior/junior.module';
|
||||
import { TaskModule } from './task/task.module';
|
||||
@Module({
|
||||
controllers: [],
|
||||
imports: [
|
||||
@ -18,17 +27,35 @@ import { HealthModule } from './health/health.module';
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => buildTypeormOptions(config, migrations),
|
||||
useFactory: (config: ConfigService) => {
|
||||
return buildTypeormOptions(config, migrations);
|
||||
},
|
||||
/* eslint-disable require-await */
|
||||
async dataSourceFactory(options) {
|
||||
if (!options) {
|
||||
throw new Error('Invalid options passed');
|
||||
}
|
||||
|
||||
return addTransactionalDataSource(new DataSource(options));
|
||||
},
|
||||
/* eslint-enable require-await */
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: (config: ConfigService) => buildLoggerOptions(config),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
I18nModule.forRoot(buildI18nOptions()),
|
||||
HealthModule,
|
||||
|
||||
// Application Modules
|
||||
// App modules
|
||||
AuthModule,
|
||||
CustomerModule,
|
||||
JuniorModule,
|
||||
TaskModule,
|
||||
GuardianModule,
|
||||
OtpModule,
|
||||
DocumentModule,
|
||||
LookupModule,
|
||||
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Pipes
|
||||
|
||||
22
src/auth/auth.module.ts
Normal file
22
src/auth/auth.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CustomerModule } from '~/customer/customer.module';
|
||||
import { AuthController } from './controllers';
|
||||
import { Device, User, UserNotificationSettings } from './entities';
|
||||
import { DeviceRepository, UserRepository } from './repositories';
|
||||
import { AuthService, DeviceService } from './services';
|
||||
import { UserService } from './services/user.service';
|
||||
import { AccessTokenStrategy } from './strategies';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserNotificationSettings, Device]),
|
||||
JwtModule.register({}),
|
||||
forwardRef(() => CustomerModule),
|
||||
],
|
||||
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
1
src/auth/constants/country-code-regex.constant..ts
Normal file
1
src/auth/constants/country-code-regex.constant..ts
Normal file
@ -0,0 +1 @@
|
||||
export const COUNTRY_CODE_REGEX = /^\+\d{1,3}$/;
|
||||
3
src/auth/constants/index.ts
Normal file
3
src/auth/constants/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './country-code-regex.constant.';
|
||||
export * from './passcode-regext.constant';
|
||||
export * from './password-regex.constant';
|
||||
1
src/auth/constants/passcode-regext.constant.ts
Normal file
1
src/auth/constants/passcode-regext.constant.ts
Normal file
@ -0,0 +1 @@
|
||||
export const PASSCODE_REGEX = /^\d{6}$/;
|
||||
1
src/auth/constants/password-regex.constant.ts
Normal file
1
src/auth/constants/password-regex.constant.ts
Normal file
@ -0,0 +1 @@
|
||||
export const PASSWORD_REGEX = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&#\-_])[A-Za-z\d@$!%*?&#\-_]{8,}$/;
|
||||
85
src/auth/controllers/auth.controller.ts
Normal file
85
src/auth/controllers/auth.controller.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { DEVICE_ID_HEADER } from '~/common/constants';
|
||||
import { AuthenticatedUser } from '~/common/decorators';
|
||||
import { AccessTokenGuard } from '~/common/guards';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import {
|
||||
CreateUnverifiedUserRequestDto,
|
||||
DisableBiometricRequestDto,
|
||||
EnableBiometricRequestDto,
|
||||
ForgetPasswordRequestDto,
|
||||
LoginRequestDto,
|
||||
SendForgetPasswordOtpRequestDto,
|
||||
SetEmailRequestDto,
|
||||
SetPasscodeRequestDto,
|
||||
VerifyUserRequestDto,
|
||||
} from '../dtos/request';
|
||||
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
|
||||
import { LoginResponseDto } from '../dtos/response/login.response.dto';
|
||||
import { IJwtPayload } from '../interfaces';
|
||||
import { AuthService } from '../services';
|
||||
|
||||
@Controller('auth')
|
||||
@ApiTags('Auth')
|
||||
@ApiBearerAuth()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
@Post('register/otp')
|
||||
async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserRequestDto) {
|
||||
const phoneNumber = await this.authService.sendRegisterOtp(createUnverifiedUserDto);
|
||||
return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber));
|
||||
}
|
||||
|
||||
@Post('register/verify')
|
||||
async verifyUser(@Body() verifyUserDto: VerifyUserRequestDto) {
|
||||
const [res, user] = await this.authService.verifyUser(verifyUserDto);
|
||||
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||
}
|
||||
|
||||
@Post('register/set-email')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) {
|
||||
await this.authService.setEmail(sub, setEmailDto);
|
||||
}
|
||||
|
||||
@Post('register/set-passcode')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) {
|
||||
await this.authService.setPasscode(sub, passcode);
|
||||
}
|
||||
|
||||
@Post('biometric/enable')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) {
|
||||
return this.authService.enableBiometric(sub, enableBiometricDto);
|
||||
}
|
||||
|
||||
@Post('biometric/disable')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) {
|
||||
return this.authService.disableBiometric(sub, disableBiometricDto);
|
||||
}
|
||||
|
||||
@Post('forget-password/otp')
|
||||
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
|
||||
const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
|
||||
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email));
|
||||
}
|
||||
|
||||
@Post('forget-password/reset')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
|
||||
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
|
||||
const [res, user] = await this.authService.login(loginDto, deviceId);
|
||||
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||
}
|
||||
}
|
||||
1
src/auth/controllers/index.ts
Normal file
1
src/auth/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './auth.controller';
|
||||
19
src/auth/dtos/request/create-unverified-user.request.dto.ts
Normal file
19
src/auth/dtos/request/create-unverified-user.request.dto.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Matches } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
|
||||
import { IsValidPhoneNumber } from '~/core/decorators/validations';
|
||||
|
||||
export class CreateUnverifiedUserRequestDto {
|
||||
@ApiProperty({ example: '+962' })
|
||||
@Matches(COUNTRY_CODE_REGEX, {
|
||||
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
|
||||
})
|
||||
countryCode: string = '+966';
|
||||
|
||||
@ApiProperty({ example: '787259134' })
|
||||
@IsValidPhoneNumber({
|
||||
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
|
||||
})
|
||||
phoneNumber!: string;
|
||||
}
|
||||
4
src/auth/dtos/request/disable-biometric.request.dto.ts
Normal file
4
src/auth/dtos/request/disable-biometric.request.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { EnableBiometricRequestDto } from './enable-biometric.request.dto';
|
||||
|
||||
export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {}
|
||||
14
src/auth/dtos/request/enable-biometric.request.dto.ts
Normal file
14
src/auth/dtos/request/enable-biometric.request.dto.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
export class EnableBiometricRequestDto {
|
||||
@ApiProperty({ example: 'device-id' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.deviceId' }) })
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ example: 'publicKey' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.publicKey' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.publicKey' }) })
|
||||
publicKey!: string;
|
||||
}
|
||||
32
src/auth/dtos/request/forget-password.request.dto.ts
Normal file
32
src/auth/dtos/request/forget-password.request.dto.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
|
||||
export class ForgetPasswordRequestDto {
|
||||
@ApiProperty({ example: 'test@test.com' })
|
||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'password' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ example: 'password' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) })
|
||||
confirmPassword!: string;
|
||||
|
||||
@ApiProperty({ example: '111111' })
|
||||
@IsNumberString(
|
||||
{ no_symbols: true },
|
||||
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
|
||||
)
|
||||
@MaxLength(DEFAULT_OTP_LENGTH, {
|
||||
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
||||
})
|
||||
@MinLength(DEFAULT_OTP_LENGTH, {
|
||||
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
||||
})
|
||||
otp!: string;
|
||||
}
|
||||
9
src/auth/dtos/request/index.ts
Normal file
9
src/auth/dtos/request/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from './create-unverified-user.request.dto';
|
||||
export * from './disable-biometric.request.dto';
|
||||
export * from './enable-biometric.request.dto';
|
||||
export * from './forget-password.request.dto';
|
||||
export * from './login.request.dto';
|
||||
export * from './send-forget-password-otp.request.dto';
|
||||
export * from './set-email.request.dto';
|
||||
export * from './set-passcode.request.dto';
|
||||
export * from './verify-user.request.dto';
|
||||
24
src/auth/dtos/request/login.request.dto.ts
Normal file
24
src/auth/dtos/request/login.request.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsEnum, IsString, ValidateIf } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { GrantType } from '~/auth/enums';
|
||||
export class LoginRequestDto {
|
||||
@ApiProperty({ example: GrantType.PASSWORD })
|
||||
@IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) })
|
||||
grantType!: GrantType;
|
||||
|
||||
@ApiProperty({ example: 'test@test.com' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) })
|
||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
|
||||
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ example: 'device-token' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) })
|
||||
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
|
||||
deviceToken!: string;
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { LoginRequestDto } from './login.request.dto';
|
||||
|
||||
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {}
|
||||
8
src/auth/dtos/request/set-email.request.dto.ts
Normal file
8
src/auth/dtos/request/set-email.request.dto.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
export class SetEmailRequestDto {
|
||||
@ApiProperty({ example: 'test@test.com' })
|
||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
|
||||
email!: string;
|
||||
}
|
||||
15
src/auth/dtos/request/set-passcode.request.dto.ts
Normal file
15
src/auth/dtos/request/set-passcode.request.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
const PASSCODE_LENGTH = 6;
|
||||
|
||||
export class SetPasscodeRequestDto {
|
||||
@ApiProperty({ example: '123456' })
|
||||
@IsNumberString(
|
||||
{ no_symbols: true },
|
||||
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.passcode' }) },
|
||||
)
|
||||
@MinLength(PASSCODE_LENGTH, { message: i18n('validation.MinLength', { path: 'general', property: 'auth.passcode' }) })
|
||||
@MaxLength(PASSCODE_LENGTH, { message: i18n('validation.MaxLength', { path: 'general', property: 'auth.passcode' }) })
|
||||
passcode!: string;
|
||||
}
|
||||
20
src/auth/dtos/request/verify-user.request.dto.ts
Normal file
20
src/auth/dtos/request/verify-user.request.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
|
||||
import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto';
|
||||
|
||||
export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto {
|
||||
@ApiProperty({ example: '111111' })
|
||||
@IsNumberString(
|
||||
{ no_symbols: true },
|
||||
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
|
||||
)
|
||||
@MaxLength(DEFAULT_OTP_LENGTH, {
|
||||
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
||||
})
|
||||
@MinLength(DEFAULT_OTP_LENGTH, {
|
||||
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
||||
})
|
||||
otp!: string;
|
||||
}
|
||||
4
src/auth/dtos/response/index.ts
Normal file
4
src/auth/dtos/response/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './send-forget-password.response.dto';
|
||||
export * from './send-register-otp.response.dto';
|
||||
export * from './user.response.dto';
|
||||
export * from './verify-user.response.dto';
|
||||
30
src/auth/dtos/response/login.response.dto.ts
Normal file
30
src/auth/dtos/response/login.response.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { User } from '~/auth/entities';
|
||||
import { ILoginResponse } from '~/auth/interfaces';
|
||||
import { CustomerResponseDto } from '~/customer/dtos/response';
|
||||
import { UserResponseDto } from './user.response.dto';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresAt!: Date;
|
||||
|
||||
@ApiProperty({ example: UserResponseDto })
|
||||
user!: UserResponseDto;
|
||||
|
||||
@ApiProperty({ example: CustomerResponseDto })
|
||||
customer!: CustomerResponseDto;
|
||||
|
||||
constructor(IVerifyUserResponse: ILoginResponse, user: User) {
|
||||
this.user = new UserResponseDto(user);
|
||||
this.customer = new CustomerResponseDto(user.customer);
|
||||
this.accessToken = IVerifyUserResponse.accessToken;
|
||||
this.refreshToken = IVerifyUserResponse.refreshToken;
|
||||
this.expiresAt = IVerifyUserResponse.expiresAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export class SendForgetPasswordOtpResponseDto {
|
||||
email!: string;
|
||||
|
||||
constructor(email: string) {
|
||||
this.email = email;
|
||||
}
|
||||
}
|
||||
10
src/auth/dtos/response/send-register-otp.response.dto.ts
Normal file
10
src/auth/dtos/response/send-register-otp.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SendRegisterOtpResponseDto {
|
||||
@ApiProperty()
|
||||
phoneNumber!: string;
|
||||
|
||||
constructor(phoneNumber: string) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
}
|
||||
36
src/auth/dtos/response/user.response.dto.ts
Normal file
36
src/auth/dtos/response/user.response.dto.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { User } from '~/auth/entities';
|
||||
import { Roles } from '~/auth/enums';
|
||||
|
||||
export class UserResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
phoneNumber!: string;
|
||||
|
||||
@ApiProperty()
|
||||
countryCode!: string;
|
||||
|
||||
@ApiProperty()
|
||||
isPasswordSet!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
isProfileCompleted!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
roles!: Roles[];
|
||||
|
||||
constructor(user: User) {
|
||||
this.id = user.id;
|
||||
this.email = user.email;
|
||||
this.phoneNumber = user.phoneNumber;
|
||||
this.countryCode = user.countryCode;
|
||||
this.isPasswordSet = user.isPasswordSet;
|
||||
this.isProfileCompleted = user.isProfileCompleted;
|
||||
this.roles = user.roles;
|
||||
}
|
||||
}
|
||||
24
src/auth/dtos/response/verify-user.response.dto.ts
Normal file
24
src/auth/dtos/response/verify-user.response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { User } from '~/auth/entities';
|
||||
import { ILoginResponse } from '~/auth/interfaces';
|
||||
|
||||
export class VerifyUserResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresAt!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
user!: User;
|
||||
|
||||
constructor(data: ILoginResponse, user: User) {
|
||||
this.accessToken = data.accessToken;
|
||||
this.refreshToken = data.refreshToken;
|
||||
this.expiresAt = data.expiresAt;
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
30
src/auth/entities/device.entity.ts
Normal file
30
src/auth/entities/device.entity.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('devices')
|
||||
export class Device {
|
||||
@PrimaryColumn('varchar', { length: 255 })
|
||||
deviceId!: string;
|
||||
|
||||
@Column('varchar', { name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@Column('varchar', { name: 'device_name', nullable: true })
|
||||
deviceName?: string | null;
|
||||
|
||||
@Column('varchar', { name: 'public_key', nullable: true })
|
||||
publicKey?: string | null;
|
||||
|
||||
@Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' })
|
||||
lastAccessOn!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.devices, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
3
src/auth/entities/index.ts
Normal file
3
src/auth/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './device.entity';
|
||||
export * from './user-notification-settings.entity';
|
||||
export * from './user.entity';
|
||||
36
src/auth/entities/user-notification-settings.entity.ts
Normal file
36
src/auth/entities/user-notification-settings.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Entity('user_notification_settings')
|
||||
export class UserNotificationSettings extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'is_email_enabled', default: false })
|
||||
isEmailEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'is_push_enabled', default: false })
|
||||
isPushEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'is_sms_enabled', default: false })
|
||||
isSmsEnabled!: boolean;
|
||||
|
||||
@OneToOne(() => User, (user) => user.notificationSettings, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
82
src/auth/entities/user.entity.ts
Normal file
82
src/auth/entities/user.entity.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Otp } from '~/common/modules/otp/entities';
|
||||
import { Customer } from '~/customer/entities/customer.entity';
|
||||
import { Document } from '~/document/entities';
|
||||
import { Roles } from '../enums';
|
||||
import { Device } from './device.entity';
|
||||
import { UserNotificationSettings } from './user-notification-settings.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'email' })
|
||||
email!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'phone_number' })
|
||||
phoneNumber!: string;
|
||||
|
||||
@Column('varchar', { length: 10, name: 'country_code' })
|
||||
countryCode!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'password', nullable: true })
|
||||
password!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'salt', nullable: true })
|
||||
salt!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'google_id' })
|
||||
googleId!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'apple_id' })
|
||||
appleId!: string;
|
||||
|
||||
@Column('boolean', { default: false, name: 'is_profile_completed' })
|
||||
isProfileCompleted!: boolean;
|
||||
|
||||
@Column('text', { nullable: true, array: true, name: 'roles' })
|
||||
roles!: Roles[];
|
||||
|
||||
@Column('varchar', { name: 'profile_picture_id', nullable: true })
|
||||
profilePictureId!: string;
|
||||
|
||||
@OneToOne(() => Document, (document) => document.user, { cascade: true, nullable: true })
|
||||
@JoinColumn({ name: 'profile_picture_id' })
|
||||
profilePicture!: Document;
|
||||
|
||||
@OneToMany(() => Otp, (otp) => otp.user)
|
||||
otp!: Otp[];
|
||||
|
||||
@OneToOne(() => UserNotificationSettings, (notificationSettings) => notificationSettings.user, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
})
|
||||
notificationSettings!: UserNotificationSettings;
|
||||
|
||||
@OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true })
|
||||
customer!: Customer;
|
||||
|
||||
@OneToMany(() => Device, (device) => device.user)
|
||||
devices!: Device[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
|
||||
updatedAt!: Date;
|
||||
|
||||
get isPasswordSet(): boolean {
|
||||
return !!this.password;
|
||||
}
|
||||
}
|
||||
4
src/auth/enums/grant-type.enum.ts
Normal file
4
src/auth/enums/grant-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum GrantType {
|
||||
PASSWORD = 'PASSWORD',
|
||||
BIOMETRIC = 'BIOMETRIC',
|
||||
}
|
||||
2
src/auth/enums/index.ts
Normal file
2
src/auth/enums/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './grant-type.enum';
|
||||
export * from './roles.enum';
|
||||
4
src/auth/enums/roles.enum.ts
Normal file
4
src/auth/enums/roles.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum Roles {
|
||||
JUNIOR = 'JUNIOR',
|
||||
GUARDIAN = 'GUARDIAN',
|
||||
}
|
||||
2
src/auth/interfaces/index.ts
Normal file
2
src/auth/interfaces/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt-payload.interface';
|
||||
export * from './login-response.interface';
|
||||
6
src/auth/interfaces/jwt-payload.interface.ts
Normal file
6
src/auth/interfaces/jwt-payload.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Roles } from '../enums';
|
||||
|
||||
export interface IJwtPayload {
|
||||
sub: string;
|
||||
roles: Roles[];
|
||||
}
|
||||
5
src/auth/interfaces/login-response.interface.ts
Normal file
5
src/auth/interfaces/login-response.interface.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface ILoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
21
src/auth/repositories/device.repository.ts
Normal file
21
src/auth/repositories/device.repository.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Device } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceRepository {
|
||||
constructor(@InjectRepository(Device) private readonly deviceRepository: Repository<Device>) {}
|
||||
|
||||
findUserDeviceById(deviceId: string, userId: string) {
|
||||
return this.deviceRepository.findOne({ where: { deviceId, userId } });
|
||||
}
|
||||
|
||||
createDevice(data: Partial<Device>) {
|
||||
return this.deviceRepository.save(data);
|
||||
}
|
||||
|
||||
updateDevice(deviceId: string, data: Partial<Device>) {
|
||||
return this.deviceRepository.update({ deviceId }, data);
|
||||
}
|
||||
}
|
||||
2
src/auth/repositories/index.ts
Normal file
2
src/auth/repositories/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './device.repository';
|
||||
export * from './user.repository';
|
||||
43
src/auth/repositories/user.repository.ts
Normal file
43
src/auth/repositories/user.repository.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsWhere, Repository } from 'typeorm';
|
||||
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
|
||||
import { User, UserNotificationSettings } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class UserRepository {
|
||||
constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) {}
|
||||
|
||||
createUnverifiedUser(data: Partial<User>) {
|
||||
return this.userRepository.save(
|
||||
this.userRepository.create({
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
roles: data.roles,
|
||||
notificationSettings: UserNotificationSettings.create(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findOne(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
|
||||
return this.userRepository.findOne({ where });
|
||||
}
|
||||
|
||||
updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) {
|
||||
user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body });
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
update(userId: string, data: Partial<User>) {
|
||||
return this.userRepository.update(userId, data);
|
||||
}
|
||||
|
||||
createUser(data: Partial<User>) {
|
||||
const user = this.userRepository.create({
|
||||
...data,
|
||||
notificationSettings: UserNotificationSettings.create(),
|
||||
});
|
||||
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
}
|
||||
265
src/auth/services/auth.service.ts
Normal file
265
src/auth/services/auth.service.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
||||
import { OtpService } from '~/common/modules/otp/services';
|
||||
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
|
||||
import {
|
||||
CreateUnverifiedUserRequestDto,
|
||||
DisableBiometricRequestDto,
|
||||
EnableBiometricRequestDto,
|
||||
ForgetPasswordRequestDto,
|
||||
LoginRequestDto,
|
||||
SendForgetPasswordOtpRequestDto,
|
||||
SetEmailRequestDto,
|
||||
} from '../dtos/request';
|
||||
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
|
||||
import { User } from '../entities';
|
||||
import { GrantType, Roles } from '../enums';
|
||||
import { ILoginResponse } from '../interfaces';
|
||||
import { removePadding, verifySignature } from '../utils';
|
||||
import { DeviceService } from './device.service';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
const ONE_THOUSAND = 1000;
|
||||
const SALT_ROUNDS = 10;
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly otpService: OtpService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
private readonly deviceService: DeviceService,
|
||||
) {}
|
||||
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
||||
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
|
||||
|
||||
return this.otpService.generateAndSendOtp({
|
||||
userId: user.id,
|
||||
recipient: user.phoneNumber,
|
||||
scope: OtpScope.VERIFY_PHONE,
|
||||
otpType: OtpType.SMS,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
||||
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
|
||||
|
||||
if (user.isPasswordSet) {
|
||||
throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED');
|
||||
}
|
||||
|
||||
const isOtpValid = await this.otpService.verifyOtp({
|
||||
userId: user.id,
|
||||
scope: OtpScope.VERIFY_PHONE,
|
||||
otpType: OtpType.SMS,
|
||||
value: verifyUserDto.otp,
|
||||
});
|
||||
|
||||
if (!isOtpValid) {
|
||||
throw new BadRequestException('USERS.INVALID_OTP');
|
||||
}
|
||||
|
||||
const updatedUser = await this.userService.verifyUserAndCreateCustomer(user);
|
||||
|
||||
const tokens = await this.generateAuthToken(updatedUser);
|
||||
|
||||
return [tokens, updatedUser];
|
||||
}
|
||||
|
||||
async setEmail(userId: string, { email }: SetEmailRequestDto) {
|
||||
const user = await this.userService.findUserOrThrow({ id: userId });
|
||||
|
||||
if (user.email) {
|
||||
throw new BadRequestException('USERS.EMAIL_ALREADY_SET');
|
||||
}
|
||||
|
||||
const existingUser = await this.userService.findUser({ email });
|
||||
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN');
|
||||
}
|
||||
|
||||
return this.userService.setEmail(userId, email);
|
||||
}
|
||||
|
||||
async setPasscode(userId: string, passcode: string) {
|
||||
const user = await this.userService.findUserOrThrow({ id: userId });
|
||||
|
||||
if (user.password) {
|
||||
throw new BadRequestException('USERS.PASSCODE_ALREADY_SET');
|
||||
}
|
||||
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
||||
const hashedPasscode = bcrypt.hashSync(passcode, salt);
|
||||
|
||||
await this.userService.setPasscode(userId, hashedPasscode, salt);
|
||||
}
|
||||
|
||||
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
|
||||
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
|
||||
|
||||
if (!device) {
|
||||
return this.deviceService.createDevice({
|
||||
deviceId,
|
||||
userId,
|
||||
publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (device.publicKey) {
|
||||
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED');
|
||||
}
|
||||
|
||||
return this.deviceService.updateDevice(deviceId, { publicKey });
|
||||
}
|
||||
|
||||
async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) {
|
||||
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
|
||||
|
||||
if (!device) {
|
||||
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (!device.publicKey) {
|
||||
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
|
||||
}
|
||||
|
||||
return this.deviceService.updateDevice(deviceId, { publicKey: null });
|
||||
}
|
||||
|
||||
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
|
||||
const user = await this.userService.findUserOrThrow({ email });
|
||||
|
||||
if (!user.isProfileCompleted) {
|
||||
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
||||
}
|
||||
|
||||
return this.otpService.generateAndSendOtp({
|
||||
userId: user.id,
|
||||
recipient: user.email,
|
||||
scope: OtpScope.FORGET_PASSWORD,
|
||||
otpType: OtpType.EMAIL,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) {
|
||||
const user = await this.userService.findUserOrThrow({ email });
|
||||
if (!user.isProfileCompleted) {
|
||||
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
||||
}
|
||||
const isOtpValid = await this.otpService.verifyOtp({
|
||||
userId: user.id,
|
||||
scope: OtpScope.FORGET_PASSWORD,
|
||||
otpType: OtpType.EMAIL,
|
||||
value: otp,
|
||||
});
|
||||
|
||||
if (!isOtpValid) {
|
||||
throw new BadRequestException('USERS.INVALID_OTP');
|
||||
}
|
||||
|
||||
this.validatePassword(password, confirmPassword, user);
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(password, user.salt);
|
||||
|
||||
await this.userService.setPasscode(user.id, hashedPassword, user.salt);
|
||||
}
|
||||
|
||||
async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
|
||||
const user = await this.userService.findUser({ email: loginDto.email });
|
||||
let tokens;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
if (loginDto.grantType === GrantType.PASSWORD) {
|
||||
tokens = await this.loginWithPassword(loginDto, user);
|
||||
} else {
|
||||
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
|
||||
}
|
||||
|
||||
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() });
|
||||
|
||||
return [tokens, user];
|
||||
}
|
||||
|
||||
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
|
||||
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAuthToken(user);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise<ILoginResponse> {
|
||||
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
|
||||
|
||||
if (!device) {
|
||||
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (!device.publicKey) {
|
||||
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
|
||||
}
|
||||
|
||||
const cleanToken = removePadding(loginDto.deviceToken);
|
||||
const isValidToken = await verifySignature(
|
||||
device.publicKey,
|
||||
cleanToken,
|
||||
`${user.email} - ${device.deviceId}`,
|
||||
'SHA1',
|
||||
);
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
|
||||
}
|
||||
|
||||
const tokens = await this.generateAuthToken(user);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async generateAuthToken(user: User) {
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.sign(
|
||||
{ sub: user.id, roles: user.roles },
|
||||
{
|
||||
expiresIn: this.configService.getOrThrow('JWT_ACCESS_TOKEN_EXPIRY'),
|
||||
secret: this.configService.getOrThrow('JWT_ACCESS_TOKEN_SECRET'),
|
||||
},
|
||||
),
|
||||
this.jwtService.sign(
|
||||
{ sub: user.id, roles: user.roles },
|
||||
{
|
||||
expiresIn: this.configService.getOrThrow('JWT_REFRESH_TOKEN_EXPIRY'),
|
||||
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
|
||||
}
|
||||
|
||||
private validatePassword(password: string, confirmPassword: string, user: User) {
|
||||
if (password !== confirmPassword) {
|
||||
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
||||
}
|
||||
|
||||
const roles = user.roles;
|
||||
|
||||
if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) {
|
||||
throw new BadRequestException('AUTH.INVALID_PASSCODE');
|
||||
}
|
||||
|
||||
if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) {
|
||||
throw new BadRequestException('AUTH.INVALID_PASSWORD');
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/auth/services/device.service.ts
Normal file
19
src/auth/services/device.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Device } from '../entities';
|
||||
import { DeviceRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceService {
|
||||
constructor(private readonly deviceRepository: DeviceRepository) {}
|
||||
findUserDeviceById(deviceId: string, userId: string) {
|
||||
return this.deviceRepository.findUserDeviceById(deviceId, userId);
|
||||
}
|
||||
|
||||
createDevice(data: Partial<Device>) {
|
||||
return this.deviceRepository.createDevice(data);
|
||||
}
|
||||
|
||||
updateDevice(deviceId: string, data: Partial<Device>) {
|
||||
return this.deviceRepository.updateDevice(deviceId, data);
|
||||
}
|
||||
}
|
||||
3
src/auth/services/index.ts
Normal file
3
src/auth/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './auth.service';
|
||||
export * from './device.service';
|
||||
export * from './user.service';
|
||||
83
src/auth/services/user.service.ts
Normal file
83
src/auth/services/user.service.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
|
||||
import { CustomerService } from '~/customer/services';
|
||||
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||
import { CreateUnverifiedUserRequestDto } from '../dtos/request';
|
||||
import { User } from '../entities';
|
||||
import { Roles } from '../enums';
|
||||
import { UserRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
|
||||
) {}
|
||||
|
||||
async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) {
|
||||
const user = await this.findUserOrThrow({ id: userId });
|
||||
|
||||
const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body))
|
||||
.notificationSettings;
|
||||
|
||||
return notificationSettings;
|
||||
}
|
||||
|
||||
findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
|
||||
return this.userRepository.findOne(where);
|
||||
}
|
||||
|
||||
async findUserOrThrow(where: FindOptionsWhere<User>) {
|
||||
const user = await this.findUser(where);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('USERS.NOT_FOUND');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findOrCreateUser({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
||||
const user = await this.userRepository.findOne({ phoneNumber });
|
||||
|
||||
if (!user) {
|
||||
return this.userRepository.createUnverifiedUser({ phoneNumber, countryCode, roles: [Roles.GUARDIAN] });
|
||||
}
|
||||
if (user && user.roles.includes(Roles.GUARDIAN) && user.isPasswordSet) {
|
||||
throw new BadRequestException('USERS.PHONE_NUMBER_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
if (user && user.roles.includes(Roles.JUNIOR)) {
|
||||
throw new BadRequestException('USERS.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
|
||||
//TODO add role Guardian to the existing user and send OTP
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async createUser(data: Partial<User>) {
|
||||
const user = await this.userRepository.createUser(data);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
setEmail(userId: string, email: string) {
|
||||
return this.userRepository.update(userId, { email });
|
||||
}
|
||||
|
||||
setPasscode(userId: string, passcode: string, salt: string) {
|
||||
return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true });
|
||||
}
|
||||
|
||||
async verifyUserAndCreateCustomer(user: User) {
|
||||
await this.customerService.createCustomer(
|
||||
{
|
||||
guardian: Guardian.create({ id: user.id }),
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
return this.findUserOrThrow({ id: user.id });
|
||||
}
|
||||
}
|
||||
20
src/auth/strategies/access-token.strategy.ts
Normal file
20
src/auth/strategies/access-token.strategy.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { IJwtPayload } from '../interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_ACCESS_TOKEN_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: IJwtPayload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
1
src/auth/strategies/index.ts
Normal file
1
src/auth/strategies/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './access-token.strategy';
|
||||
26
src/auth/utils/crypt.ts
Normal file
26
src/auth/utils/crypt.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export function verifySignature(
|
||||
publicKeyBase64: string,
|
||||
signatureBase64: string,
|
||||
message: string,
|
||||
algorithm: 'SHA1' | 'SHA384',
|
||||
) {
|
||||
const signatureBuffer = Buffer.from(signatureBase64, 'base64');
|
||||
|
||||
const publicKeyPEM = '-----BEGIN PUBLIC KEY-----\n' + publicKeyBase64 + '\n-----END PUBLIC KEY-----';
|
||||
const verifier = crypto.createVerify(algorithm);
|
||||
verifier.update(message, 'utf8');
|
||||
return verifier.verify(
|
||||
{
|
||||
key: publicKeyPEM,
|
||||
padding: crypto.constants.RSA_PKCS1_PADDING,
|
||||
},
|
||||
signatureBuffer,
|
||||
);
|
||||
}
|
||||
|
||||
export function removePadding(originalSignature: string) {
|
||||
const buffer = Buffer.from(originalSignature, 'base64');
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
1
src/auth/utils/index.ts
Normal file
1
src/auth/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './crypt';
|
||||
1
src/common/constants/global.constant.ts
Normal file
1
src/common/constants/global.constant.ts
Normal file
@ -0,0 +1 @@
|
||||
export const DEVICE_ID_HEADER = 'x-client-id';
|
||||
1
src/common/constants/index.ts
Normal file
1
src/common/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './global.constant';
|
||||
5
src/common/decorators/allowed-roles.decorator.ts
Normal file
5
src/common/decorators/allowed-roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Roles } from '~/auth/enums';
|
||||
|
||||
export const ROLE_METADATA_KEY = 'roles';
|
||||
export const AllowedRoles = (...roles: Roles[]) => SetMetadata(ROLE_METADATA_KEY, roles);
|
||||
3
src/common/decorators/index.ts
Normal file
3
src/common/decorators/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './allowed-roles.decorator';
|
||||
export * from './public.decorator';
|
||||
export * from './user.decorator';
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
6
src/common/decorators/user.decorator.ts
Normal file
6
src/common/decorators/user.decorator.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const AuthenticatedUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user;
|
||||
});
|
||||
23
src/common/guards/access-token.guard.ts
Normal file
23
src/common/guards/access-token.guard.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Observable } from 'rxjs';
|
||||
import { IS_PUBLIC_KEY } from '../decorators';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard extends AuthGuard('access-token') {
|
||||
constructor(protected reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
2
src/common/guards/index.ts
Normal file
2
src/common/guards/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './access-token.guard';
|
||||
export * from './roles-guard';
|
||||
28
src/common/guards/roles-guard.ts
Normal file
28
src/common/guards/roles-guard.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { ROLE_METADATA_KEY } from '../decorators';
|
||||
import { AccessTokenGuard } from './access-token.guard';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard extends AccessTokenGuard {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
await super.canActivate(context);
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const allowedRoles = this.reflector.getAllAndOverride<Roles[]>(ROLE_METADATA_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!allowedRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allowedRoles.some((role) => user.roles.includes(role));
|
||||
}
|
||||
}
|
||||
1
src/common/modules/lookup/controllers/index.ts
Normal file
1
src/common/modules/lookup/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './lookup.controller';
|
||||
23
src/common/modules/lookup/controllers/lookup.controller.ts
Normal file
23
src/common/modules/lookup/controllers/lookup.controller.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AccessTokenGuard } from '~/common/guards';
|
||||
import { ApiDataArrayResponse } from '~/core/decorators';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||
import { LookupService } from '../services';
|
||||
|
||||
@Controller('lookup')
|
||||
@ApiTags('Lookups')
|
||||
@ApiBearerAuth()
|
||||
export class LookupController {
|
||||
constructor(private readonly lookupService: LookupService) {}
|
||||
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@Get('default-avatars')
|
||||
@ApiDataArrayResponse(DocumentMetaResponseDto)
|
||||
async findDefaultAvatars() {
|
||||
const avatars = await this.lookupService.findDefaultAvatar();
|
||||
|
||||
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
|
||||
}
|
||||
}
|
||||
11
src/common/modules/lookup/lookup.module.ts
Normal file
11
src/common/modules/lookup/lookup.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentModule } from '~/document/document.module';
|
||||
import { LookupController } from './controllers';
|
||||
import { LookupService } from './services';
|
||||
|
||||
@Module({
|
||||
controllers: [LookupController],
|
||||
providers: [LookupService],
|
||||
imports: [DocumentModule],
|
||||
})
|
||||
export class LookupModule {}
|
||||
1
src/common/modules/lookup/services/index.ts
Normal file
1
src/common/modules/lookup/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './lookup.service';
|
||||
11
src/common/modules/lookup/services/lookup.service.ts
Normal file
11
src/common/modules/lookup/services/lookup.service.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DocumentType } from '~/document/enums';
|
||||
import { DocumentService } from '~/document/services';
|
||||
|
||||
@Injectable()
|
||||
export class LookupService {
|
||||
constructor(private readonly documentService: DocumentService) {}
|
||||
findDefaultAvatar() {
|
||||
return this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
|
||||
}
|
||||
}
|
||||
1
src/common/modules/otp/constants/index.ts
Normal file
1
src/common/modules/otp/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './otp-default.constant';
|
||||
2
src/common/modules/otp/constants/otp-default.constant.ts
Normal file
2
src/common/modules/otp/constants/otp-default.constant.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const DEFAULT_OTP_LENGTH = 6;
|
||||
export const DEFAULT_OTP_DIGIT = '1';
|
||||
1
src/common/modules/otp/entities/index.ts
Normal file
1
src/common/modules/otp/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './otp.entity';
|
||||
33
src/common/modules/otp/entities/otp.entity.ts
Normal file
33
src/common/modules/otp/entities/otp.entity.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { User } from '~/auth/entities';
|
||||
import { OtpScope, OtpType } from '../enums';
|
||||
|
||||
@Entity('otp')
|
||||
export class Otp {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'value' })
|
||||
value!: string;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', { length: 255, name: 'scope' })
|
||||
scope!: OtpScope;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'otp_type' })
|
||||
otpType!: OtpType;
|
||||
|
||||
@Column('timestamp with time zone', { name: 'expires_at' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', { name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.otp, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
2
src/common/modules/otp/enums/index.ts
Normal file
2
src/common/modules/otp/enums/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './otp-scope.enum';
|
||||
export * from './otp-type.enum';
|
||||
4
src/common/modules/otp/enums/otp-scope.enum.ts
Normal file
4
src/common/modules/otp/enums/otp-scope.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum OtpScope {
|
||||
VERIFY_PHONE = 'VERIFY_PHONE',
|
||||
FORGET_PASSWORD = 'FORGET_PASSWORD',
|
||||
}
|
||||
4
src/common/modules/otp/enums/otp-type.enum.ts
Normal file
4
src/common/modules/otp/enums/otp-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum OtpType {
|
||||
SMS = 'SMS',
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
2
src/common/modules/otp/interfaces/index.ts
Normal file
2
src/common/modules/otp/interfaces/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './send-otp.interface';
|
||||
export * from './verify-otp.interface';
|
||||
9
src/common/modules/otp/interfaces/send-otp.interface.ts
Normal file
9
src/common/modules/otp/interfaces/send-otp.interface.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { OtpScope, OtpType } from '../enums';
|
||||
|
||||
export interface ISendOtp {
|
||||
userId: string;
|
||||
scope: OtpScope;
|
||||
language?: string;
|
||||
otpType: OtpType;
|
||||
recipient: string;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { OtpScope, OtpType } from '../enums';
|
||||
|
||||
export interface IVerifyOtp {
|
||||
userId: string;
|
||||
scope: OtpScope;
|
||||
otpType: OtpType;
|
||||
value: string;
|
||||
}
|
||||
13
src/common/modules/otp/otp.module.ts
Normal file
13
src/common/modules/otp/otp.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Otp } from './entities';
|
||||
import { OtpRepository } from './repositories';
|
||||
import { OtpService } from './services/otp.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Otp])],
|
||||
providers: [OtpService, OtpRepository],
|
||||
exports: [OtpService],
|
||||
})
|
||||
export class OtpModule {}
|
||||
1
src/common/modules/otp/repositories/index.ts
Normal file
1
src/common/modules/otp/repositories/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './otp.repository';
|
||||
40
src/common/modules/otp/repositories/otp.repository.ts
Normal file
40
src/common/modules/otp/repositories/otp.repository.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { Otp } from '../entities';
|
||||
import { IVerifyOtp } from '../interfaces';
|
||||
const FIVE = 5;
|
||||
const SIXTY = 60;
|
||||
const ONE_THOUSAND = 1000;
|
||||
const FIVE_MINUTES_IN_MILLISECONDS = FIVE * SIXTY * ONE_THOUSAND;
|
||||
@Injectable()
|
||||
export class OtpRepository {
|
||||
constructor(@InjectRepository(Otp) private readonly otpRepository: Repository<Otp>) {}
|
||||
|
||||
createOtp(otp: Partial<Otp>) {
|
||||
return this.otpRepository.save(
|
||||
this.otpRepository.create({
|
||||
userId: otp.userId,
|
||||
value: otp.value,
|
||||
scope: otp.scope,
|
||||
otpType: otp.otpType,
|
||||
expiresAt: new Date(Date.now() + FIVE_MINUTES_IN_MILLISECONDS),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findOtp(otp: IVerifyOtp) {
|
||||
return this.otpRepository.findOne({
|
||||
where: {
|
||||
userId: otp.userId,
|
||||
scope: otp.scope,
|
||||
value: otp.value,
|
||||
otpType: otp.otpType,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/common/modules/otp/services/index.ts
Normal file
1
src/common/modules/otp/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './otp.service';
|
||||
35
src/common/modules/otp/services/otp.service.ts
Normal file
35
src/common/modules/otp/services/otp.service.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DEFAULT_OTP_DIGIT, DEFAULT_OTP_LENGTH } from '../constants';
|
||||
import { OtpType } from '../enums';
|
||||
import { ISendOtp, IVerifyOtp } from '../interfaces';
|
||||
import { OtpRepository } from '../repositories';
|
||||
import { generateRandomOtp } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
constructor(private readonly configService: ConfigService, private readonly otpRepository: OtpRepository) {}
|
||||
private useMock = this.configService.get<boolean>('USE_MOCK', false);
|
||||
async generateAndSendOtp(sendotpRequest: ISendOtp): Promise<string> {
|
||||
const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH);
|
||||
|
||||
await this.otpRepository.createOtp({ ...sendotpRequest, value: otp });
|
||||
|
||||
this.sendOtp(sendotpRequest, otp);
|
||||
|
||||
return sendotpRequest.otpType == OtpType.EMAIL
|
||||
? sendotpRequest.recipient
|
||||
: sendotpRequest.recipient?.replace(/.(?=.{4})/g, '*');
|
||||
}
|
||||
|
||||
async verifyOtp(verifyOtpRequest: IVerifyOtp) {
|
||||
const otp = await this.otpRepository.findOtp(verifyOtpRequest);
|
||||
|
||||
return !!otp;
|
||||
}
|
||||
|
||||
private sendOtp(sendotpRequest: ISendOtp, otp: string) {
|
||||
// TODO: send OTP to the user
|
||||
return;
|
||||
}
|
||||
}
|
||||
1
src/common/modules/otp/utils/index.ts
Normal file
1
src/common/modules/otp/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './otp-generator.util';
|
||||
9
src/common/modules/otp/utils/otp-generator.util.ts
Normal file
9
src/common/modules/otp/utils/otp-generator.util.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { getRandomValues } from 'crypto';
|
||||
import { shuffle } from 'lodash';
|
||||
const ZERO = 0;
|
||||
const ONE = 1;
|
||||
export function generateRandomOtp(length: number): string {
|
||||
const u32 = getRandomValues(new Uint32Array(ONE))[ZERO];
|
||||
const randomOtpDigits = u32.toString().substring(ZERO, length).padEnd(length, '0');
|
||||
return shuffle(randomOtpDigits).join('');
|
||||
}
|
||||
@ -1 +1,3 @@
|
||||
// placeholder
|
||||
export * from './is-above-18';
|
||||
export * from './is-valid-phone-number';
|
||||
|
||||
42
src/core/decorators/validations/is-above-18.ts
Normal file
42
src/core/decorators/validations/is-above-18.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import moment from 'moment';
|
||||
const EIGHTTEEN = 18;
|
||||
export function IsAbove18(validationOptions?: ValidationOptions) {
|
||||
return (object: any, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'IsAbove18',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: `${propertyName} must be above 18 years old`,
|
||||
...validationOptions,
|
||||
},
|
||||
constraints: [],
|
||||
validator: IsAbove18Constraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'IsAbove18' })
|
||||
export class IsAbove18Constraint implements ValidatorConstraintInterface {
|
||||
validate(value: any, args: ValidationArguments) {
|
||||
if (!value) return true;
|
||||
|
||||
const dateOfBirth = moment(value);
|
||||
|
||||
if (!dateOfBirth.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const today = moment();
|
||||
const age = today.diff(dateOfBirth, 'years');
|
||||
|
||||
return age >= EIGHTTEEN;
|
||||
}
|
||||
}
|
||||
40
src/core/decorators/validations/is-valid-phone-number.ts
Normal file
40
src/core/decorators/validations/is-valid-phone-number.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import * as libphonenumber from 'google-libphonenumber';
|
||||
|
||||
const phoneUtil = libphonenumber.PhoneNumberUtil.getInstance();
|
||||
|
||||
export function IsValidPhoneNumber(validationOptions?: ValidationOptions) {
|
||||
return (object: any, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'IsValidPhoneNumber',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: `${propertyName} must be valid mobile number`,
|
||||
...validationOptions,
|
||||
},
|
||||
constraints: [],
|
||||
validator: IsValidPhoneNumberConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'IsValidPhoneNumber' })
|
||||
export class IsValidPhoneNumberConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: any, args: ValidationArguments) {
|
||||
const countryCode = (args.object as any).countryCode; // +962;
|
||||
const isoCountryCode = phoneUtil.getRegionCodeForCountryCode(+countryCode); // JO
|
||||
try {
|
||||
const parsedNumber = phoneUtil.parse(value, isoCountryCode);
|
||||
return phoneUtil.isValidNumberForRegion(parsedNumber, isoCountryCode);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ describe('ValidationPipe', () => {
|
||||
transform: true,
|
||||
validateCustomDecorators: true,
|
||||
stopAtFirstError: true,
|
||||
forbidNonWhitelisted: false,
|
||||
forbidNonWhitelisted: true,
|
||||
dismissDefaultMessages: true,
|
||||
enableDebugMessages: true,
|
||||
exceptionFactory: i18nValidationErrorFactory,
|
||||
|
||||
@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe {
|
||||
transform: true,
|
||||
validateCustomDecorators: true,
|
||||
stopAtFirstError: true,
|
||||
forbidNonWhitelisted: false,
|
||||
forbidNonWhitelisted: true,
|
||||
dismissDefaultMessages: true,
|
||||
enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV,
|
||||
exceptionFactory: i18nValidationErrorFactory,
|
||||
|
||||
37
src/customer/controllers/customer.controller.ts
Normal file
37
src/customer/controllers/customer.controller.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
||||
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
|
||||
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response';
|
||||
import { CustomerService } from '../services';
|
||||
|
||||
@Controller('customers')
|
||||
@ApiTags('Customers')
|
||||
@ApiBearerAuth()
|
||||
export class CustomerController {
|
||||
constructor(private readonly customerService: CustomerService) {}
|
||||
|
||||
@Patch('')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.GUARDIAN)
|
||||
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
|
||||
const customer = await this.customerService.updateCustomer(sub, body);
|
||||
|
||||
return ResponseFactory.data(new CustomerResponseDto(customer));
|
||||
}
|
||||
|
||||
@Patch('settings/notifications')
|
||||
@UseGuards(AccessTokenGuard)
|
||||
async updateNotificationSettings(
|
||||
@AuthenticatedUser() { sub }: IJwtPayload,
|
||||
@Body() body: UpdateNotificationsSettingsRequestDto,
|
||||
) {
|
||||
const notificationSettings = await this.customerService.updateNotificationSettings(sub, body);
|
||||
|
||||
return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings));
|
||||
}
|
||||
}
|
||||
1
src/customer/controllers/index.ts
Normal file
1
src/customer/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './customer.controller';
|
||||
15
src/customer/customer.module.ts
Normal file
15
src/customer/customer.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from '~/auth/auth.module';
|
||||
import { CustomerController } from './controllers';
|
||||
import { Customer } from './entities';
|
||||
import { CustomerRepository } from './repositories/customer.repository';
|
||||
import { CustomerService } from './services';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Customer]), forwardRef(() => AuthModule)],
|
||||
controllers: [CustomerController],
|
||||
providers: [CustomerService, CustomerRepository],
|
||||
exports: [CustomerService],
|
||||
})
|
||||
export class CustomerModule {}
|
||||
2
src/customer/dtos/request/index.ts
Normal file
2
src/customer/dtos/request/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './update-customer.request.dto';
|
||||
export * from './update-notifications-settings.request.dto';
|
||||
29
src/customer/dtos/request/update-customer.request.dto.ts
Normal file
29
src/customer/dtos/request/update-customer.request.dto.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { IsAbove18 } from '~/core/decorators/validations';
|
||||
export class UpdateCustomerRequestDto {
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
|
||||
@IsOptional()
|
||||
firstName!: string;
|
||||
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
|
||||
@IsOptional()
|
||||
lastName!: string;
|
||||
|
||||
@ApiProperty({ example: 'JO' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) })
|
||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) })
|
||||
@IsOptional()
|
||||
countryOfResidence!: string;
|
||||
|
||||
@ApiProperty({ example: '2021-01-01' })
|
||||
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
|
||||
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
|
||||
@IsOptional()
|
||||
dateOfBirth!: Date;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateNotificationsSettingsRequestDto {
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isEmailEnabled!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPushEnabled!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSmsEnabled!: boolean;
|
||||
}
|
||||
71
src/customer/dtos/response/customer-response.dto.ts
Normal file
71
src/customer/dtos/response/customer-response.dto.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Customer } from '~/customer/entities';
|
||||
|
||||
export class CustomerResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
customerStatus!: string;
|
||||
|
||||
@ApiProperty()
|
||||
rejectionReason!: string;
|
||||
|
||||
@ApiProperty()
|
||||
firstName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
lastName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
dateOfBirth!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
nationalId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
nationaIdExpiry!: Date;
|
||||
|
||||
@ApiProperty()
|
||||
countryOfResidence!: string;
|
||||
|
||||
@ApiProperty()
|
||||
sourceOfIncome!: string;
|
||||
|
||||
@ApiProperty()
|
||||
profession!: string;
|
||||
|
||||
@ApiProperty()
|
||||
professionType!: string;
|
||||
|
||||
@ApiProperty()
|
||||
isPep!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
gender!: string;
|
||||
|
||||
@ApiProperty()
|
||||
isJunior!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
isGuardian!: boolean;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
this.id = customer.id;
|
||||
this.customerStatus = customer.customerStatus;
|
||||
this.rejectionReason = customer.rejectionReason;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.dateOfBirth = customer.dateOfBirth;
|
||||
this.nationalId = customer.nationalId;
|
||||
this.nationaIdExpiry = customer.nationaIdExpiry;
|
||||
this.countryOfResidence = customer.countryOfResidence;
|
||||
this.sourceOfIncome = customer.sourceOfIncome;
|
||||
this.profession = customer.profession;
|
||||
this.professionType = customer.professionType;
|
||||
this.isPep = customer.isPep;
|
||||
this.gender = customer.gender;
|
||||
this.isJunior = customer.isJunior;
|
||||
this.isGuardian = customer.isGuardian;
|
||||
}
|
||||
}
|
||||
2
src/customer/dtos/response/index.ts
Normal file
2
src/customer/dtos/response/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './customer-response.dto';
|
||||
export * from './notification-settings.response.dto';
|
||||
@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserNotificationSettings } from '~/auth/entities';
|
||||
|
||||
export class NotificationSettingsResponseDto {
|
||||
@ApiProperty()
|
||||
isEmailEnabled!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
isPushEnabled!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
isSmsEnabled!: boolean;
|
||||
|
||||
constructor(notificationSettings: UserNotificationSettings) {
|
||||
this.isEmailEnabled = notificationSettings.isEmailEnabled;
|
||||
this.isPushEnabled = notificationSettings.isPushEnabled;
|
||||
this.isSmsEnabled = notificationSettings.isSmsEnabled;
|
||||
}
|
||||
}
|
||||
83
src/customer/entities/customer.entity.ts
Normal file
83
src/customer/entities/customer.entity.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '~/auth/entities';
|
||||
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||
import { Junior } from '~/junior/entities';
|
||||
|
||||
@Entity()
|
||||
export class Customer extends BaseEntity {
|
||||
@PrimaryColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column('varchar', { length: 255, default: 'PENDING', name: 'customer_status' })
|
||||
customerStatus!: string;
|
||||
|
||||
@Column('text', { nullable: true, name: 'rejection_reason' })
|
||||
rejectionReason!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'first_name' })
|
||||
firstName!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'last_name' })
|
||||
lastName!: string;
|
||||
|
||||
@Column('date', { nullable: true, name: 'date_of_birth' })
|
||||
dateOfBirth!: Date;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'national_id' })
|
||||
nationalId!: string;
|
||||
|
||||
@Column('date', { nullable: true, name: 'national_id_expiry' })
|
||||
nationaIdExpiry!: Date;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
|
||||
countryOfResidence!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
|
||||
sourceOfIncome!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'profession' })
|
||||
profession!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'profession_type' })
|
||||
professionType!: string;
|
||||
|
||||
@Column('boolean', { default: false, name: 'is_pep' })
|
||||
isPep!: boolean;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true, name: 'gender' })
|
||||
gender!: string;
|
||||
|
||||
@Column('boolean', { default: false, name: 'is_junior' })
|
||||
isJunior!: boolean;
|
||||
|
||||
@Column('boolean', { default: false, name: 'is_guardian' })
|
||||
isGuardian!: boolean;
|
||||
|
||||
@Column('varchar', { name: 'user_id' })
|
||||
userId!: string;
|
||||
|
||||
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
@OneToOne(() => Junior, (junior) => junior.customer, { cascade: true })
|
||||
junior!: Junior;
|
||||
|
||||
@OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true })
|
||||
guardian!: Guardian;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
1
src/customer/entities/index.ts
Normal file
1
src/customer/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './customer.entity';
|
||||
32
src/customer/repositories/customer.repository.ts
Normal file
32
src/customer/repositories/customer.repository.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsWhere, Repository } from 'typeorm';
|
||||
import { User } from '~/auth/entities';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { UpdateCustomerRequestDto } from '../dtos/request';
|
||||
import { Customer } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerRepository {
|
||||
constructor(@InjectRepository(Customer) private readonly customerRepository: Repository<Customer>) {}
|
||||
|
||||
updateCustomer(id: string, data: UpdateCustomerRequestDto) {
|
||||
return this.customerRepository.update(id, data);
|
||||
}
|
||||
|
||||
findOne(where: FindOptionsWhere<Customer>) {
|
||||
return this.customerRepository.findOne({ where });
|
||||
}
|
||||
|
||||
createCustomer(customerData: Partial<Customer>, user: User) {
|
||||
return this.customerRepository.save(
|
||||
this.customerRepository.create({
|
||||
...customerData,
|
||||
id: user.id,
|
||||
user,
|
||||
isGuardian: user.roles.includes(Roles.GUARDIAN),
|
||||
isJunior: user.roles.includes(Roles.JUNIOR),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
src/customer/repositories/index.ts
Normal file
0
src/customer/repositories/index.ts
Normal file
34
src/customer/services/customer.service.ts
Normal file
34
src/customer/services/customer.service.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { User } from '~/auth/entities';
|
||||
import { UserService } from '~/auth/services/user.service';
|
||||
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
|
||||
import { Customer } from '../entities';
|
||||
import { CustomerRepository } from '../repositories/customer.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerService {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => UserService)) private readonly userService: UserService,
|
||||
private readonly customerRepository: CustomerRepository,
|
||||
) {}
|
||||
updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto) {
|
||||
return this.userService.updateNotificationSettings(userId, data);
|
||||
}
|
||||
|
||||
async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> {
|
||||
await this.customerRepository.updateCustomer(userId, data);
|
||||
return this.findCustomerById(userId);
|
||||
}
|
||||
|
||||
createCustomer(customerData: Partial<Customer>, user: User) {
|
||||
return this.customerRepository.createCustomer(customerData, user);
|
||||
}
|
||||
|
||||
async findCustomerById(id: string) {
|
||||
const customer = await this.customerRepository.findOne({ id });
|
||||
if (!customer) {
|
||||
throw new BadRequestException('CUSTOMER.NOT_FOUND');
|
||||
}
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
1
src/customer/services/index.ts
Normal file
1
src/customer/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './customer.service';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user