Compare commits

..
Author SHA1 Message Date
jain 9db620d818 forgot, reset and change password 2023-06-26 10:38:49 +05:30
jain 89982f851f forgot password and email verification 2023-06-24 18:17:32 +05:30
jain 8bc3a307c0 Initial Commit 2023-06-23 19:13:49 +05:30
dilipshrivastwa-kiwi 501deaac0b Merge pull request #1 from KiwiTechLLC/dks_sprint1
add git ignore file
2023-06-15 15:12:38 +05:30
dilipshrivastwa-kiwi 5d386322e4 add git ignore file 2023-06-15 15:11:18 +05:30
dilipshrivastwa-kiwi ecb058209d First Commit 2023-06-15 15:01:10 +05:30
256 changed files with 2027 additions and 21461 deletions
-31
View File
@@ -1,31 +0,0 @@
NODE_ENV=dev
PORT=5000
DB_HOST=
DB_PASS=
DB_USER=postgres
DB_PORT=5432
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=
OCI_FINGERPRINT=
OCI_PRIVATE_KEY=
OCI_PASSPHRASE=""
OCI_NAMESPACE=
OCI_REGION=
MAIL_HOST=smtp.gmail.com
MAIL_USER=[email protected]
MAIL_PASSWORD=
MAIL_PORT=587
MAIL_FROM=UBA
-47
View File
@@ -1,47 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:security/recommended'],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js', 'node_modules', 'dist', 'coverage', 'jest*\\.ts', 'typeorm*\\.ts'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-inferrable-types': 'off',
'func-names': ['error', 'as-needed'],
'no-underscore-dangle': ['error'],
'require-await': ['error'],
'no-console': ['error'],
'no-multi-assign': ['error'],
'no-magic-numbers': ['error', { ignoreArrayIndexes: true }],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
'max-len': [
'error',
{ code: 120, tabWidth: 2, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true },
],
},
overrides: [
{
files: ['*spec.ts'],
rules: {
'no-magic-numbers': ['off'],
},
},
],
};
-85
View File
@@ -1,85 +0,0 @@
name: CI Zod
on:
pull_request:
branches:
- '*'
push:
branches:
- dev
- main
env:
NODE_ENV: development
TAG: dev
jobs:
install_dependencies:
name: Install Dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Cache Node Modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node_modules-
run_tests:
name: Run Tests
runs-on: ubuntu-latest
needs: install_dependencies
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Run Lint
run: npm run lint
- name: Run Tests with Coverage
run: npm run test:cov
build_app:
name: Build Application
runs-on: ubuntu-latest
needs: run_tests
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 22.4.1
- name: Install Dependencies
run: npm install
- name: Build Application
run: npm run build
- name: Upload Build Artifacts
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
+21 -52
View File
@@ -1,55 +1,24 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
/static
/media
.idea/
*.pyc
media/
*.name
*.iml
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
*.xml
*.pyo
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.idea
venv/*
static/*
*.pem
*.sqlite3
/migrations/__pycache__/
/__pycache__/
/*.pyc
*/__pycache__/*.pyc
__pycache__/
*.env
ve/*
-6
View File
@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 120
}
-6
View File
@@ -1,6 +0,0 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
-33
View File
@@ -1,33 +0,0 @@
# Base image
FROM node:22.4.1-alpine
RUN apk update && \
apk upgrade && \
apk add ca-certificates curl && \
update-ca-certificates
#saudi timezone
ENV TZ=Asia/Riyadh
# Create app directory
RUN mkdir /app && chown node:node /app
WORKDIR /app
# finally switch user to node
USER node
# A wildcard is used to ensure both package.json, package-lock.json and package-lock.json are copied
COPY --chown=node:node package*.json ./
# Install app dependencies
RUN npm install --frozen-lockfile
# Bundle app source
COPY --chown=node:node . .
# Creates a "dist" folder with the production build
RUN npm run build
# Start the server using the production build
CMD [ "node", "dist/main" ]
+1 -99
View File
@@ -1,99 +1 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
# ZODBank-Backend
-35
View File
@@ -1,35 +0,0 @@
import type { Config as JestConfig } from 'jest';
import { pathsToModuleNameMapper } from 'ts-jest';
import { compilerOptions as tsConfigCompilerOptions } from './tsconfig.json';
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
type JestProjectConfig = Exclude<ArrayElement<JestConfig['projects']>, string>;
const baseJestConfig: JestConfig = {
// ...
};
const baseJestProjectConfig: JestProjectConfig = {
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
moduleNameMapper: pathsToModuleNameMapper(tsConfigCompilerOptions.paths, { prefix: '<rootDir>' }),
globalSetup: '<rootDir>/test/global-setup.ts',
};
const projects: { name?: string; rootDir: string }[] = [{ rootDir: '.' }];
export function buildJestConfig(config?: {
jestConfigs?: JestConfig;
jestProjectConfigs?: JestProjectConfig;
}): JestConfig {
return {
...baseJestConfig,
...config?.jestConfigs,
projects: projects.map(({ name, rootDir }) => ({
...baseJestProjectConfig,
...config?.jestProjectConfigs,
...(name && { displayName: name }),
rootDir,
})),
};
}
-16
View File
@@ -1,16 +0,0 @@
import type { Config } from 'jest';
import { buildJestConfig } from './jest.base.config';
const config: Config = buildJestConfig({
jestProjectConfigs: {
testRegex: '.*\\.spec\\.ts$',
coveragePathIgnorePatterns: ['__testing__', 'entities', '<rootDir>/src/db', 'index.ts'],
},
jestConfigs: {
coverageDirectory: 'coverage',
collectCoverageFrom: ['<rootDir>/src/**/*.(t|j)s'],
coverageReporters: ['clover', 'json', 'lcov', 'text', 'text-summary', 'cobertura'],
},
});
export default config;
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"watchAssets": true,
"assets": [
{
"include": "config",
"exclude": "**/*.md"
},
"i18n",
"files"
]
}
}
-16376
View File
File diff suppressed because it is too large Load Diff
-109
View File
@@ -1,109 +0,0 @@
{
"name": "test",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm:cli": "./node_modules/.bin/ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm",
"typeorm:cli-d": "ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm -d typeorm.cli.ts",
"migration:generate": "npm run typeorm:cli-d migration:generate",
"migration:create": "npm run typeorm:cli migration:create",
"migration:up": "npm run typeorm:cli-d migration:run",
"migration:down": "npm run typeorm:cli-d migration:revert"
},
"dependencies": {
"@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0",
"@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",
"@nestjs/throttler": "^6.2.1",
"@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",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
},
"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",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1",
"jest": "^29.5.0",
"lint-staged": "^13.2.2",
"prettier": "^2.8.8",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"lint-staged": {
"*.{js,ts}": [
"eslint --fix"
],
"*.{js,ts,json,md}": [
"prettier --write"
]
}
}
-55
View File
@@ -1,55 +0,0 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
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 { AuthModule } from './auth/auth.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 { HealthModule } from './health/health.module';
@Module({
controllers: [],
imports: [
ConfigModule.forRoot(buildConfigOptions()),
TypeOrmModule.forRootAsync({
imports: [],
inject: [ConfigService],
useFactory: (config: ConfigService) => buildTypeormOptions(config, migrations),
}),
LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config),
inject: [ConfigService],
}),
I18nModule.forRoot(buildI18nOptions()),
// App modules
AuthModule,
CustomerModule,
DocumentModule,
HealthModule,
OtpModule,
],
providers: [
// Global Pipes
{
inject: [ConfigService],
provide: APP_PIPE,
useFactory: (config: ConfigService) => buildValidationPipe(config),
},
// Global Filters (order matters)
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_FILTER, useValue: buildI18nValidationExceptionFilter() },
],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(I18nMiddleware).forRoutes('*');
}
}
-17
View File
@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
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({})],
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
controllers: [AuthController],
exports: [UserService],
})
export class AuthModule {}
@@ -1 +0,0 @@
export const COUNTRY_CODE_REGEX = /^\+\d{1,3}$/;
-1
View File
@@ -1 +0,0 @@
export * from './country-code-regex.constant.';
-71
View File
@@ -1,71 +0,0 @@
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,
LoginRequestDto,
SetEmailRequestDto,
SetPasscodeRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { 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('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
View File
@@ -1 +0,0 @@
export * from './auth.controller';
@@ -1,19 +0,0 @@
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;
}
@@ -1,4 +0,0 @@
import { PickType } from '@nestjs/swagger';
import { EnableBiometricRequestDto } from './enable-biometric.request.dto';
export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {}
@@ -1,14 +0,0 @@
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;
}
-7
View File
@@ -1,7 +0,0 @@
export * from './create-unverified-user.request.dto';
export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto';
export * from './login.request.dto';
export * from './set-email.request.dto';
export * from './set-passcode.request.dto';
export * from './verify-user.request.dto';
@@ -1,24 +0,0 @@
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: '[email protected]' })
@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;
}
@@ -1,8 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class SetEmailRequestDto {
@ApiProperty({ example: '[email protected]' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
}
@@ -1,15 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
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;
}
-3
View File
@@ -1,3 +0,0 @@
export * from './send-register-otp.response.dto';
export * from './user.response.dto';
export * from './verify-user.response.dto';
@@ -1,30 +0,0 @@
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;
}
}
@@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpResponseDto {
@ApiProperty()
phoneNumber!: string;
constructor(phoneNumber: string) {
this.phoneNumber = phoneNumber;
}
}
@@ -1,36 +0,0 @@
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;
}
}
@@ -1,24 +0,0 @@
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
View File
@@ -1,30 +0,0 @@
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
View File
@@ -1,3 +0,0 @@
export * from './device.entity';
export * from './user-notification-settings.entity';
export * from './user.entity';
@@ -1,36 +0,0 @@
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
View File
@@ -1,82 +0,0 @@
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
View File
@@ -1,4 +0,0 @@
export enum GrantType {
PASSWORD = 'PASSWORD',
BIOMETRIC = 'BIOMETRIC',
}
-2
View File
@@ -1,2 +0,0 @@
export * from './grant-type.enum';
export * from './roles.enum';
-4
View File
@@ -1,4 +0,0 @@
export enum Roles {
JUNIOR = 'JUNIOR',
GUARDIAN = 'GUARDIAN',
}
-2
View File
@@ -1,2 +0,0 @@
export * from './jwt-payload.interface';
export * from './login-response.interface';
@@ -1,4 +0,0 @@
export interface IJwtPayload {
sub: string;
roles: string[];
}
@@ -1,5 +0,0 @@
export interface ILoginResponse {
accessToken: string;
refreshToken: string;
expiresAt: Date;
}
@@ -1,21 +0,0 @@
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
View File
@@ -1,2 +0,0 @@
export * from './device.repository';
export * from './user.repository';
-41
View File
@@ -1,41 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
import { Customer } from '~/customer/entities';
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>) {
return this.userRepository.findOne({ where });
}
updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) {
user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body });
return this.userRepository.save(user);
}
verifyUserAndCreateCustomer(user: User) {
user.customer = Customer.create({ ...user.customer, id: user.id, isGuardian: true });
return this.userRepository.save(user);
}
update(userId: string, data: Partial<User>) {
return this.userRepository.update(userId, data);
}
}
-208
View File
@@ -1,208 +0,0 @@
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 {
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
LoginRequestDto,
SetEmailRequestDto,
} from '../dtos/request';
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
import { User } from '../entities';
import { GrantType } 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,
phoneNumber: 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 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) };
}
}
-19
View File
@@ -1,19 +0,0 @@
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
View File
@@ -1,3 +0,0 @@
export * from './auth.service';
export * from './device.service';
export * from './user.service';
-64
View File
@@ -1,64 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
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) {}
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>) {
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)) {
//TODO add role Guardian to the existing user and send OTP
}
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 });
}
verifyUserAndCreateCustomer(user: User) {
return this.userRepository.verifyUserAndCreateCustomer(user);
}
}
@@ -1,20 +0,0 @@
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
View File
@@ -1 +0,0 @@
export * from './access-token.strategy';
-26
View File
@@ -1,26 +0,0 @@
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
View File
@@ -1 +0,0 @@
export * from './crypt';
-1
View File
@@ -1 +0,0 @@
export const DEVICE_ID_HEADER = 'x-client-id';
-1
View File
@@ -1 +0,0 @@
export * from './global.constant';
-2
View File
@@ -1,2 +0,0 @@
export * from './public.decorator';
export * from './user.decorator';
@@ -1,4 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
-6
View File
@@ -1,6 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const AuthenticatedUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user;
});
-23
View File
@@ -1,23 +0,0 @@
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(private 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);
}
}
-1
View File
@@ -1 +0,0 @@
export * from './access-token.guard';
@@ -1 +0,0 @@
export * from './otp-default.constant';
@@ -1,2 +0,0 @@
export const DEFAULT_OTP_LENGTH = 6;
export const DEFAULT_OTP_DIGIT = '1';
@@ -1,20 +0,0 @@
import { IsNotEmpty } from 'class-validator';
import { UserLocale } from '~/core/enums';
import { OtpScope, OtpType } from '../../enums';
export class GenerateOtpRequestDto {
@IsNotEmpty()
userId!: string;
@IsNotEmpty()
phoneNumber!: string;
@IsNotEmpty()
scope!: OtpScope;
@IsNotEmpty()
language?: UserLocale = UserLocale.ENGLISH;
@IsNotEmpty()
otpType!: OtpType;
}
@@ -1,2 +0,0 @@
export * from './generate-otp-request.request.dto';
export * from './verify-otp-request.dto';
@@ -1,16 +0,0 @@
import { IsNotEmpty } from 'class-validator';
import { OtpScope, OtpType } from '../../enums';
export class VerifyOtpRequestDto {
@IsNotEmpty()
userId!: string;
@IsNotEmpty()
scope!: OtpScope;
@IsNotEmpty()
otpType!: OtpType;
@IsNotEmpty()
value!: string;
}
-1
View File
@@ -1 +0,0 @@
export * from './otp.entity';
@@ -1,33 +0,0 @@
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
View File
@@ -1,2 +0,0 @@
export * from './otp-scope.enum';
export * from './otp-type.enum';
@@ -1,3 +0,0 @@
export enum OtpScope {
VERIFY_PHONE = 'VERIFY_PHONE',
}
@@ -1,4 +0,0 @@
export enum OtpType {
SMS = 'SMS',
EMAIL = 'EMAIL',
}
-13
View File
@@ -1,13 +0,0 @@
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 +0,0 @@
export * from './otp.repository';
@@ -1,40 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm';
import { VerifyOtpRequestDto } from '../dtos/request';
import { Otp } from '../entities';
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: VerifyOtpRequestDto) {
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
View File
@@ -1 +0,0 @@
export * from './otp.service';
@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DEFAULT_OTP_DIGIT, DEFAULT_OTP_LENGTH } from '../constants';
import { GenerateOtpRequestDto, VerifyOtpRequestDto } from '../dtos/request';
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: GenerateOtpRequestDto) {
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.phoneNumber.replace(/.(?=.{4})/g, '*');
}
async verifyOtp(verifyOtpRequest: VerifyOtpRequestDto) {
const otp = await this.otpRepository.findOtp(verifyOtpRequest);
return !!otp;
}
private sendOtp(sendotpRequest: GenerateOtpRequestDto, otp: string) {
// TODO: send OTP to the user
return;
}
}
-1
View File
@@ -1 +0,0 @@
export * from './otp-generator.util';
@@ -1,9 +0,0 @@
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
View File
@@ -1 +0,0 @@
export const LANGUAGE_HEADER_NAME = 'Accept-Language';
-1
View File
@@ -1 +0,0 @@
export * from './headers.constant';
@@ -1,73 +0,0 @@
const apiExtraModelsMock = jest.fn();
const apiResponseMock = jest.fn();
const getSchemaPathMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({
ApiExtraModels: apiExtraModelsMock,
ApiResponse: apiResponseMock,
getSchemaPath: getSchemaPathMock,
}));
import { HttpStatus } from '@nestjs/common';
import { ApiDataArrayResponse } from './data-array-response.decorator';
class MockModel {}
describe('ApiDataArrayResponse', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Model responses', () => {
it.each([
{
passedStatus: undefined,
expectedStatus: HttpStatus.OK,
},
{
passedStatus: HttpStatus.CREATED,
expectedStatus: HttpStatus.CREATED,
},
])('should return ApiDataArrayResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
const modelSchemaPathMock = 'model-path';
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
ApiDataArrayResponse(MockModel, passedStatus);
expect(apiExtraModelsMock).toHaveBeenCalledWith(MockModel);
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
expect(apiResponseMock).toHaveBeenCalledWith({
status: expectedStatus,
description: 'Successful list response',
schema: {
properties: {
data: {
type: 'array',
items: { $ref: modelSchemaPathMock },
},
},
},
});
});
});
describe('Primitive responses', () => {
it('should return ApiDataArrayResponse decorator with proper settings', () => {
ApiDataArrayResponse('boolean');
expect(apiExtraModelsMock).not.toHaveBeenCalled();
expect(getSchemaPathMock).not.toHaveBeenCalled();
expect(apiResponseMock).toHaveBeenCalledWith({
status: HttpStatus.OK,
description: 'Successful list response',
schema: {
properties: {
data: {
type: 'array',
items: { type: 'boolean' },
},
},
},
});
});
});
});
@@ -1,35 +0,0 @@
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
/**
* Define the response structure of the endpoint as a list of items response.
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
*/
export const ApiDataArrayResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
const decorators = [];
const modelProperties: { $ref?: string; type?: string } = {};
if (typeof model !== 'string') {
decorators.push(ApiExtraModels(model));
modelProperties.$ref = getSchemaPath(model);
} else {
modelProperties.type = model;
}
decorators.push(
ApiResponse({
status,
description: 'Successful list response',
schema: {
properties: {
data: {
type: 'array',
items: modelProperties,
},
},
},
}),
);
return applyDecorators(...decorators);
};
@@ -1,92 +0,0 @@
const apiExtraModelsMock = jest.fn();
const apiResponseMock = jest.fn();
const getSchemaPathMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({
ApiExtraModels: apiExtraModelsMock,
ApiResponse: apiResponseMock,
getSchemaPath: getSchemaPathMock,
ApiPropertyOptional: () => jest.fn(),
ApiProperty: () => jest.fn(),
}));
import { HttpStatus } from '@nestjs/common';
import { DataPageResponseDto } from '~/core/dtos';
import { ApiDataPageResponse } from './data-page-response.decorator';
class MockModel {}
describe('ApiDataPageResponse', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Model responses', () => {
it.each([
{
passedStatus: undefined,
expectedStatus: HttpStatus.OK,
},
{
passedStatus: HttpStatus.CREATED,
expectedStatus: HttpStatus.CREATED,
},
])('should return ApiDataPageResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
const modelSchemaPathMock = 'model-path';
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
const dataPageSchemaPathMock = 'data-page-path';
getSchemaPathMock.mockReturnValueOnce(dataPageSchemaPathMock);
ApiDataPageResponse(MockModel, passedStatus);
expect(apiExtraModelsMock).toHaveBeenCalledWith(DataPageResponseDto, MockModel);
expect(getSchemaPathMock).toHaveBeenCalledWith(DataPageResponseDto);
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
expect(apiResponseMock).toHaveBeenCalledWith({
status: expectedStatus,
description: 'Successful paginated list response',
schema: {
allOf: [
{ $ref: dataPageSchemaPathMock },
{
properties: {
data: {
type: 'array',
items: { $ref: modelSchemaPathMock },
},
},
},
],
},
});
});
});
describe('Primitive responses', () => {
it('should return ApiDataPageResponse decorator with proper settings', () => {
const dataPageSchemaPathMock = 'data-page-path';
getSchemaPathMock.mockReturnValueOnce(dataPageSchemaPathMock);
ApiDataPageResponse('boolean');
expect(apiExtraModelsMock).toHaveBeenCalledWith(DataPageResponseDto);
expect(getSchemaPathMock).toHaveBeenCalledWith(DataPageResponseDto);
expect(apiResponseMock).toHaveBeenCalledWith({
status: HttpStatus.OK,
description: 'Successful paginated list response',
schema: {
allOf: [
{ $ref: dataPageSchemaPathMock },
{
properties: {
data: {
type: 'array',
items: { type: 'boolean' },
},
},
},
],
},
});
});
});
});
@@ -1,42 +0,0 @@
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { DataPageResponseDto } from '~/core/dtos';
/**
* Define the response structure of the endpoint as a page of items response.
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
*/
export const ApiDataPageResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
const decorators = [];
const modelProperties: { $ref?: string; type?: string } = {};
if (typeof model !== 'string') {
decorators.push(ApiExtraModels(DataPageResponseDto, model));
modelProperties.$ref = getSchemaPath(model);
} else {
decorators.push(ApiExtraModels(DataPageResponseDto));
modelProperties.type = model;
}
decorators.push(
ApiResponse({
status,
description: 'Successful paginated list response',
schema: {
allOf: [
{ $ref: getSchemaPath(DataPageResponseDto) },
{
properties: {
data: {
type: 'array',
items: modelProperties,
},
},
},
],
},
}),
);
return applyDecorators(...decorators);
};
@@ -1,67 +0,0 @@
const apiExtraModelsMock = jest.fn();
const apiResponseMock = jest.fn();
const getSchemaPathMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({
ApiExtraModels: apiExtraModelsMock,
ApiResponse: apiResponseMock,
getSchemaPath: getSchemaPathMock,
}));
import { HttpStatus } from '@nestjs/common';
import { ApiDataResponse } from './data-response.decorator';
class MockModel {}
describe('ApiDataResponse', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Model responses', () => {
it.each([
{
passedStatus: undefined,
expectedStatus: HttpStatus.OK,
},
{
passedStatus: HttpStatus.CREATED,
expectedStatus: HttpStatus.CREATED,
},
])('should return ApiDataResponse decorator with proper settings', ({ passedStatus, expectedStatus }) => {
const modelSchemaPathMock = 'model-path';
getSchemaPathMock.mockReturnValueOnce(modelSchemaPathMock);
ApiDataResponse(MockModel, passedStatus);
expect(apiExtraModelsMock).toHaveBeenCalledWith(MockModel);
expect(getSchemaPathMock).toHaveBeenCalledWith(MockModel);
expect(apiResponseMock).toHaveBeenCalledWith({
status: expectedStatus,
description: 'Successful single item response',
schema: {
properties: {
data: { $ref: modelSchemaPathMock },
},
},
});
});
});
describe('Primitive responses', () => {
it('should return ApiDataResponse decorator with proper settings', () => {
ApiDataResponse('boolean');
expect(apiExtraModelsMock).not.toHaveBeenCalled();
expect(getSchemaPathMock).not.toHaveBeenCalled();
expect(apiResponseMock).toHaveBeenCalledWith({
status: HttpStatus.OK,
description: 'Successful single item response',
schema: {
properties: {
data: { type: 'boolean' },
},
},
});
});
});
});
@@ -1,32 +0,0 @@
import { applyDecorators, HttpStatus, Type } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
/**
* Define the response structure of the endpoint as a single item response.
* We can pass complex model type such as DTOs, or primitive names as strings (e.g. 'boolean').
*/
export const ApiDataResponse = <TModel extends Type>(model: TModel | string, status = HttpStatus.OK) => {
const decorators = [];
const modelProperties: { $ref?: string; type?: string } = {};
if (typeof model !== 'string') {
decorators.push(ApiExtraModels(model));
modelProperties.$ref = getSchemaPath(model);
} else {
modelProperties.type = model;
}
decorators.push(
ApiResponse({
status,
description: 'Successful single item response',
schema: {
properties: {
data: modelProperties,
},
},
}),
);
return applyDecorators(...decorators);
};
@@ -1,17 +0,0 @@
const apiForbiddenResponseMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({
ApiForbiddenResponse: apiForbiddenResponseMock,
ApiProperty: () => jest.fn(),
}));
import { ApiForbiddenResponseBody } from './forbidden-response-body.decorator';
describe('ApiForbiddenResponseBody', () => {
it('should return api forbidden response decorator with proper settings', () => {
ApiForbiddenResponseBody();
expect(apiForbiddenResponseMock).toHaveBeenCalledWith({
type: expect.any(Function),
});
});
});
@@ -1,22 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiForbiddenResponse, ApiProperty } from '@nestjs/swagger';
import { ErrorCategory } from '~/core/enums';
import { IFieldError, IResponseError } from '~/core/interfaces';
/**
* Define forbidden (403) response in api endpoint
*/
export const ApiForbiddenResponseBody = () => {
return applyDecorators(ApiForbiddenResponse({ type: ForbiddenError }));
};
class ForbiddenError implements IResponseError {
@ApiProperty({ example: ErrorCategory.FORBIDDEN_ERROR })
category!: ErrorCategory;
@ApiProperty({ example: 'Do not have permission to do this action' })
message!: string;
@ApiProperty({ example: [] })
errors!: IFieldError[];
}
-6
View File
@@ -1,6 +0,0 @@
export * from './data-array-response.decorator';
export * from './data-page-response.decorator';
export * from './data-response.decorator';
export * from './forbidden-response-body.decorator';
export * from './lang-request-header.decorator';
export * from './unauthorized-response-body.decorator';
@@ -1,21 +0,0 @@
const apiHeaderMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({ ApiHeader: apiHeaderMock }));
import { LANGUAGE_HEADER_NAME } from '~/core/constants';
import { UserLocale } from '~/core/enums';
import { ApiLangRequestHeader } from './lang-request-header.decorator';
describe('ApiLangRequestHeader', () => {
it('should return api header decorator with proper settings', () => {
ApiLangRequestHeader();
expect(apiHeaderMock).toHaveBeenCalledWith({
name: LANGUAGE_HEADER_NAME,
schema: {
enum: Object.values(UserLocale),
default: UserLocale.ENGLISH,
example: UserLocale.ARABIC,
},
});
});
});
@@ -1,20 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiHeader } from '@nestjs/swagger';
import { LANGUAGE_HEADER_NAME } from '~/core/constants';
import { UserLocale } from '~/core/enums';
/**
* Define language request header 'Accept-Language' in api endpoint
*/
export const ApiLangRequestHeader = () => {
return applyDecorators(
ApiHeader({
name: LANGUAGE_HEADER_NAME,
schema: {
enum: Object.values(UserLocale),
default: UserLocale.ENGLISH,
example: UserLocale.ARABIC,
},
}),
);
};
@@ -1,17 +0,0 @@
const apiUnauthorizedResponseMock = jest.fn();
jest.mock('@nestjs/swagger', () => ({
ApiUnauthorizedResponse: apiUnauthorizedResponseMock,
ApiProperty: () => jest.fn(),
}));
import { ApiUnauthorizedResponseBody } from './unauthorized-response-body.decorator';
describe('ApiUnauthorizedResponseBody', () => {
it('should return api unauthorized response decorator with proper settings', () => {
ApiUnauthorizedResponseBody();
expect(apiUnauthorizedResponseMock).toHaveBeenCalledWith({
type: expect.any(Function),
});
});
});
@@ -1,22 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { ErrorCategory } from '~/core/enums';
import { IFieldError, IResponseError } from '~/core/interfaces';
/**
* Define unauthorized (401) response in api endpoint
*/
export const ApiUnauthorizedResponseBody = () => {
return applyDecorators(ApiUnauthorizedResponse({ type: UnauthorizedError }));
};
class UnauthorizedError implements IResponseError {
@ApiProperty({ example: ErrorCategory.UNAUTHORIZED_ERROR })
category!: ErrorCategory;
@ApiProperty({ example: 'You have to login again' })
message!: string;
@ApiProperty({ example: [] })
errors!: IFieldError[];
}
-2
View File
@@ -1,2 +0,0 @@
export * from './api';
// export * from './validations';
-3
View File
@@ -1,3 +0,0 @@
// placeholder
export * from './is-above-18';
export * from './is-valid-phone-number';
@@ -1,42 +0,0 @@
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;
}
}
@@ -1,40 +0,0 @@
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;
}
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from './requests';
export * from './responses';
-1
View File
@@ -1 +0,0 @@
export * from './page-options.request.dto';
@@ -1,106 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { PageOptionsRequestDto } from './page-options.request.dto';
describe('PageOptionsRequestDto', () => {
describe('page', () => {
it('should be optional (default 1)', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, {});
const errors = validateSync(pageOptions);
expect(errors).toEqual([]);
expect(pageOptions.page).toEqual(1);
});
it('should transform to number', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: '2' });
const errors = validateSync(pageOptions);
expect(errors).toEqual([]);
expect(pageOptions.page).toEqual(2);
});
it('should be number only', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 'abc' });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('page');
expect(errors[0].constraints).toHaveProperty('isNumber');
});
it('should be an integer only', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 1.5 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('page');
expect(errors[0].constraints).toHaveProperty('isInt');
});
it('should be minimum of 1', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { page: 0 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('page');
expect(errors[0].constraints).toHaveProperty('min');
});
it('should be maximum of 1_000_000_000_000', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 1_000_000_000_001 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('size');
expect(errors[0].constraints).toHaveProperty('max');
});
});
describe('size', () => {
it('should be optional (default 10)', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, {});
const errors = validateSync(pageOptions);
expect(errors).toEqual([]);
expect(pageOptions.size).toEqual(10);
});
it('should transform to number', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: '2' });
const errors = validateSync(pageOptions);
expect(errors).toEqual([]);
expect(pageOptions.size).toEqual(2);
});
it('should be number only', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 'abc' });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('size');
expect(errors[0].constraints).toHaveProperty('isNumber');
});
it('should be an integer only', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 1.5 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('size');
expect(errors[0].constraints).toHaveProperty('isInt');
});
it('should be minimum of 1', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 0 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('size');
expect(errors[0].constraints).toHaveProperty('min');
});
it('should be maximum of 50', () => {
const pageOptions = plainToInstance(PageOptionsRequestDto, { size: 51 });
const errors = validateSync(pageOptions);
expect(errors[0].property).toEqual('size');
expect(errors[0].constraints).toHaveProperty('max');
});
});
});
@@ -1,46 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsNumber, Max, Min } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
const MIN_PAGINATION_PAGE = 1;
const MAX_PAGINATION_PAGE = 10000000;
const MIN_PAGINATION_SIZE = 1;
const MAX_PAGINATION_SIZE = 50;
const DEFAULT_PAGINATION_PAGE = 1;
const DEFAULT_PAGINATION_SIZE = 10;
export class PageOptionsRequestDto {
@ApiPropertyOptional({
minimum: MIN_PAGINATION_PAGE,
default: DEFAULT_PAGINATION_PAGE,
example: DEFAULT_PAGINATION_PAGE,
description: 'Pagination page',
})
@Max(MAX_PAGINATION_PAGE, { message: i18n('validation.Max', { path: 'general', property: 'paginationPage' }) })
@Min(MIN_PAGINATION_PAGE, { message: i18n('validation.Min', { path: 'general', property: 'paginationPage' }) })
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'paginationPage' }) })
@IsNumber(
{ allowNaN: false },
{ message: i18n('validation.IsNumber', { path: 'general', property: 'paginationPage' }) },
)
@Transform(({ value }) => +value)
page: number = DEFAULT_PAGINATION_PAGE;
@ApiPropertyOptional({
minimum: MIN_PAGINATION_SIZE,
maximum: MAX_PAGINATION_SIZE,
default: DEFAULT_PAGINATION_SIZE,
example: DEFAULT_PAGINATION_SIZE,
description: 'Pagination page size',
})
@Max(MAX_PAGINATION_SIZE, { message: i18n('validation.Max', { path: 'general', property: 'paginationSize' }) })
@Min(MIN_PAGINATION_SIZE, { message: i18n('validation.Min', { path: 'general', property: 'paginationSize' }) })
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'paginationSize' }) })
@IsNumber(
{ allowNaN: false },
{ message: i18n('validation.IsNumber', { path: 'general', property: 'paginationSize' }) },
)
@Transform(({ value }) => +value)
size: number = DEFAULT_PAGINATION_SIZE;
}

Some files were not shown because too many files have changed in this diff Show More