init from boilerplate

This commit is contained in:
lovebird 2024-05-25 10:36:47 +02:00
parent 55570dee59
commit 72d7659219
475 changed files with 39924 additions and 55 deletions

31
.dockerignore Normal file
View File

@ -0,0 +1,31 @@
#husky
# Build dependencies
node_modules/
coverage/
coverage-e2e/
coverage-integration/
dist/
.husky/
.github/
prod/
# Logs
logs/
# Environment (contains sensitive data)
.env*
# Versioning and metadata
.git
.gitignore
.dockerignore
.eslintignore
# Files not required for production
.editorconfig
dockerfile
docker-compose.yml
cspell.json
README.md
nodemon.json

52
.env.example Normal file
View File

@ -0,0 +1,52 @@
APP_NAME=NEST
APP_ENV=development
APP_LANGUAGE=en
HTTP_ENABLE=true
HTTP_HOST=localhost
HTTP_PORT= 3000
HTTP_VERSIONING_ENABLE=true
HTTP_VERSION=1
DEBUGGER_HTTP_WRITE_INTO_FILE=false
DEBUGGER_HTTP_WRITE_INTO_CONSOLE=false
DEBUGGER_SYSTEM_WRITE_INTO_FILE=false
DEBUGGER_SYSTEM_WRITE_INTO_CONSOLE=false
JOB_ENABLE=false
DATABASE_HOST=mongodb://localhost:30001,localhost:30002,localhost:30003
DATABASE_NAME=nest
DATABASE_USER=
DATABASE_PASSWORD=
DATABASE_DEBUG=false
DATABASE_OPTIONS=replicaSet=rs0&retryWrites=true&w=majority
AUTH_JWT_SUBJECT=nestDevelopment
AUTH_JWT_ISSUER=nest
AUTH_JWT_AUDIENCE=https://example.com
AUTH_JWT_ACCESS_TOKEN_SECRET_KEY=85huyujDurLdvLsjAW93XsqP79rAotqplHCOEWj1wzyIcMtT
AUTH_JWT_ACCESS_TOKEN_EXPIRED=15m
AUTH_JWT_REFRESH_TOKEN_SECRET_KEY=7Y3nqaO8jKVOFBRy9ujn5uUxV8Iy2otHrnQgiXlIGAqiVdb5
AUTH_JWT_REFRESH_TOKEN_EXPIRED=7d
AUTH_JWT_REFRESH_TOKEN_REMEMBER_ME_EXPIRED=30d
AUTH_JWT_REFRESH_TOKEN_NOT_BEFORE_EXPIRATION=15m
AUTH_PERMISSION_TOKEN_SECRET_KEY=85huyujDurLdvLsjAW93XsqP79rAotqplHCOEWj1wzyIcMtT
AUTH_PERMISSION_TOKEN_EXPIRED=5m
AUTH_JWT_PAYLOAD_ENCRYPT=false
AUTH_JWT_PAYLOAD_ACCESS_TOKEN_ENCRYPT_KEY=fKyRq7g9eftVNEdiCC7lNU6fga5Pr1iC7dc0JYsC
AUTH_JWT_PAYLOAD_ACCESS_TOKEN_ENCRYPT_IV=mLZZdQrXqjPW5F5H2eko
AUTH_JWT_PAYLOAD_REFRESH_TOKEN_ENCRYPT_KEY=NnCnSrRmw5YuQyTPtDokOWmKR37EYbuB6ITZqqZd
AUTH_JWT_PAYLOAD_REFRESH_TOKEN_ENCRYPT_IV=eP7P8Pmvq207zhyd61dz
AUTH_PAYLOAD_PERMISSION_TOKEN_ENCRYPT_KEY=hUcRIUQzJMe17w8cAZAreMdjxjo1JbucBACu7tAw
AUTH_PAYLOAD_PERMISSION_TOKEN_ENCRYPT_IV=7V0D5a0D3SdsgM1KT5rF
AWS_CREDENTIAL_KEY=
AWS_CREDENTIAL_SECRET=
AWS_S3_REGION=ap-southeast-3
AWS_S3_BUCKET=baibay-development

9
.eslintignore Normal file
View File

@ -0,0 +1,9 @@
# /node_modules/* in the project root is ignored by default
# build artefacts
dist/*
coverage/*
node_modules/*
logs/*
prod/*
.husky/*
.github/*

23
.eslintrc Normal file
View File

@ -0,0 +1,23 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"tsconfigRootDir": ".",
"sourceType": "module"
},
"plugins": ["@typescript-eslint/eslint-plugin"],
"extends": [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"root": true,
"env": {
"node": true,
"jest": true
},
"ignorePatterns": [".eslintrc.js"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
}

14
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: tuesday
time: "00:00"
open-pull-requests-limit: 3
target-branch: "development"
commit-message:
prefix: "github-action"
labels:
- dependabot

31
.github/workflows/linter.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Linter
on:
workflow_dispatch:
pull_request:
branches:
- master
- development
jobs:
linter:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['18.x']
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Setup node version ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Linter
run: yarn lint

45
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Release
permissions:
contents: write
on:
# push:
# branches:
# - main
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Get short sha commit
id: git
run: |
echo "short_sha=$(git rev-parse --short $GITHUB_SHA)" >> "$GITHUB_OUTPUT"
- name: Get latest version
id: version
uses: martinbeentjes/npm-get-version-action@main
- name: Git
run: |
echo Branch name is: ${{ github.ref_name }}
echo Short sha: ${{ steps.git.outputs.short_sha }}
echo Version is: ${{ steps.version.outputs.current-version }}
- name: Release
uses: softprops/action-gh-release@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.version.outputs.current-version }}
name: v${{ steps.version.outputs.current-version }}
generate_release_notes: true
draft: false
prerelease: false

51
.gitignore vendored
View File

@ -1,4 +1,53 @@
# compiled output
/dist
/node_modules /node_modules
/coverage .warmup/
# Logs
logs
*.log *.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store .DS_Store
# Tests
/coverage
/coverage-e2e
/coverage-integration
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# yarn
.yarn/*
.pnp.*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# environment
.env*
!.env.example
config.yaml
config.yml

6
.husky/pre-commit Normal file
View File

@ -0,0 +1,6 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint
yarn deadcode
yarn spell

View File

@ -1,4 +0,0 @@
./docs
./scripts
./tests
./incoming

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 4
}

View File

@ -1,9 +0,0 @@
Copyright (c) <year> <owner> All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) [2023] [Fedi DAYEG]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

515
README.md
View File

@ -1,3 +1,514 @@
# osr-package-template [![Contributors][nest-contributors-shield]][nest-contributors]
[![Forks][nest-forks-shield]][nest-forks]
[![Stargazers][nest-stars-shield]][nest-stars]
[![Issues][nest-issues-shield]][nest-issues]
[![MIT License][nest-license-shield]][license]
Package basics [![NestJs][nestjs-shield]][ref-nestjs]
[![NodeJs][nodejs-shield]][ref-nodejs]
[![Typescript][typescript-shield]][ref-typescript]
[![MongoDB][mongodb-shield]][ref-mongodb]
[![JWT][jwt-shield]][ref-jwt]
[![Jest][jest-shield]][ref-jest]
[![Yarn][yarn-shield]][ref-yarn]
[![Docker][docker-shield]][ref-docker]
# Complete NestJs Boilerplate 🔥 🚀
> This repo will representative of authentication service and authorization service
[Complete NestJs][nest] is a [Http NestJs v9.x][ref-nestjs] boilerplate. Best uses for backend service.
*You can [request feature][nest-issues] or [report bug][nest-issues] with following this link*
## Table of contents
* [Important](#important)
* [Next Todo](#next-todo)
* [Build With](#build-with)
* [Objective](#objective)
* [Features](#features)
* [Structure](#structure)
* [Folder Structure](#folder-structure)
* [Module Structure](#module-structure)
* [Response Structure](#response-structure)
* [Prerequisites](#prerequisites)
* [Getting Started](#getting-started)
* [Clone Repo](#clone-repo)
* [Install Dependencies](#install-dependencies)
* [Create environment](#create-environment)
* [Database Migration](#database-migration)
* [Test](#test)
* [Run Project](#run-project)
* [Run Project with Docker](#run-project-with-docker)
* [API Reference](#api-reference)
* [Environment](#environment)
* [Api Key Encryption](#api-key-encryption)
* [Adjust Mongoose Setting](#adjust-mongoose-setting)
* [License](#license)
* [Contact](#contact)
## Important
* The features will replated with AWS Features
* If you want to implement `database transactions`, you must run MongoDB as a `replication set`.
* If you change the environment value of `APP_ENV` to `production`, that will trigger.
1. CorsMiddleware will implement `src/configs/middleware.config.ts`.
2. Documentation will `disable`.
## Next Todo
Next development
* [x] serialization update
* [x] update unit test for common modules
* [x] user e2e
* [x] api key for saas, change `x-api-key` to `${key}:${secret}`
* [ ] user plan module
* [ ] invoicing plan module
* [ ] excel decorator optimize
* [ ] Authorization optimize, remove permission entity and implement policy guard
* [ ] Google SSO
* [ ] Background export/import from/to CSV and Excel
* [ ] Update Documentation, include an diagram for easier comprehension
## Build with
Describes which version.
| Name | Version |
| ---------- | -------- |
| NestJs | v9.3.x |
| NodeJs | v18.12.x |
| Typescript | v5.0.x |
| Mongoose | v7.0.x |
| MongoDB | v6.0.x |
| Yarn | v1.22.x |
| NPM | v8.19.x |
| Docker | v20.10.x |
| Docker Compose | v2.6.x |
| Swagger | v6.2.x |
## Objective
* Easy to maintenance
* NestJs Habit
* Component based folder structure
* Stateless authentication and authorization
* Repository Design Pattern or Data Access Layer Design Pattern
* Follow Community Guide Line
* Follow The Twelve-Factor App
* Adopt SOLID and KISS principle
* Support Microservice Architecture, Serverless Architecture, Clean Architecture, and/or Hexagonal Architecture
## Features
### Main Features
* NestJs v9.x 🥳
* Typescript 🚀
* Production ready 🔥
* Repository Design Pattern (Multi Repository, can mix with `TypeORM`)
* Swagger / OpenAPI 3 included
* Authentication (`Access Token`, `Refresh Token`, `API Key`, and `Google SSO`)
* Authorization, Role and Permission Management (`PermissionToken`)
* Support multi-language `i18n` 🗣, can controllable with request header `x-custom-lang`
* Request validation for all request params, query, dan body with `class-validation`
* Serialization with `class-transformer`
* Url Versioning, default version is `1`
* Server Side Pagination
* Import and export data with CSV or Excel by using `decorator`
### Database
* MongoDB integrate by using [mongoose][ref-mongoose] 🎉
* Multi Database
* Database Transaction
* Database Soft Delete
* Database Migration
### Logger and Debugger
* Logger with `Morgan`
* Debugger with `Winston` 📝
### Security
* Apply `helmet`, `cors`, and `rate-limit`
* Timeout awareness and can override ⌛️
* User agent awareness, and can whitelist user agent
### Setting
* Support environment file
* Centralize configuration 🤖
* Centralize response
* Centralize exception filter
* Setting from database 🗿
### Third Party Integration
* Storage integration with `AwsS3`
* Upload file `single` and `multipart` to AwsS3
### Others
* Support Docker installation
* Support CI/CD with Github Action or Jenkins
* Husky GitHook for check source code, and run test before commit 🐶
* Linter with EsLint for Typescript
## Structure
### Folder Structure
1. `/app` The final wrapper module
2. `/common` The common module
3. `/configs` The configurations for this project
4. `/health` health check module for every service integrated
5. `/jobs` cron job or schedule task
6. `/language` json languages
7. `/migration` migrate all init data
8. `/modules` other modules based on service based on project
9. `/router` endpoint router. `Controller` will put in this
### Module structure
Full structure of module
```txt
.
└── module1
├── abstracts
├── constants // constant like enum, static value, status code, etc
├── controllers // business logic for rest api
├── decorators // warper decorator, custom decorator, etc
├── dtos // request validation
├── docs // swagger / OpenAPI 3
├── errors // custom error
├── filters // custom filter
├── guards // validate related with database
├── indicators // custom health check indicator
├── interceptors // custom interceptors
├── interfaces
├── middleware
├── pipes
├── repository
├── entities // database entities
├── repositories // database repositories
└── module1.repository.module.ts
├── serializations // response serialization
├── services
├── tasks // task for cron job
└── module1.module.ts
```
### Response Structure
This section will describe the structure of the response.
#### Response Default
Default response for the response
```ts
export class ResponseDefaultSerialization {
statusCode: number;
message: string;
_metadata?: IResponseMetadata;
data?: Record<string, any>;
}
```
#### Response Paging
Default response for pagination.
```ts
export class ResponsePagingSerialization {
statusCode: number;
message: string;
totalData: number;
totalPage?: number;
currentPage?: number;
perPage?: number;
_availableSearch?: string[];
_availableSort?: string[];
_metadata?: IResponseMetadata;
data: Record<string, any>[];
}
```
#### Response Metadata
This is useful when we need to give the frontend some information that is related / not related with the endpoint.
```ts
export interface IResponseMetadata {
languages: ENUM_MESSAGE_LANGUAGE[];
timestamp: number;
timezone: string;
requestId: string;
path: string;
version: string;
repoVersion: string;
nextPage?: string;
previousPage?: string;
firstPage?: string;
lastPage?: string;
[key: string]: any;
}
```
## Prerequisites
We assume that everyone who comes here is **`programmer with intermediate knowledge`** and we also need to understand more before we begin in order to reduce the knowledge gap.
1. Understand [NestJs Fundamental][ref-nestjs], Main Framework. NodeJs Framework with support fully TypeScript.
2. Understand[Typescript Fundamental][ref-typescript], Programming Language. It will help us to write and read the code.
3. Understand [ExpressJs Fundamental][ref-nodejs], NodeJs Base Framework. It will help us in understanding how the NestJs Framework works.
4. Understand what NoSql is and how it works as a database, especially [MongoDB.][ref-mongodb]
5. Understand Repository Design Pattern or Data Access Object Design Pattern. It will help to read, and write the source code
6. Understand The SOLID Principle and KISS Principle for better write the code.
7. Optional. Understand Microservice Architecture, Clean Architecture, and/or Hexagonal Architecture. It can help to serve the project.
8. Optional. Understanding [The Twelve Factor Apps][ref-12factor]. It can help to serve the project.
9. Optional. Understanding [Docker][ref-docker]. It can help to run the project.
## Getting Started
Before start, we need to install some packages and tools.
The recommended version is the LTS version for every tool and package.
> Make sure to check that the tools have been installed successfully.
1. [NodeJs][ref-nodejs]
2. [MongoDB][ref-mongodb]
3. [Yarn][ref-yarn]
4. [Git][ref-git]
### Clone Repo
Clone the project with git.
```bash
git clone https://github.com/fedi-dayeg/complete-nestjs-boilerplate.git
```
### Install Dependencies
This project needs some dependencies. Let's go install it.
```bash
yarn install
```
### Create environment
Make your own environment file with a copy of `env.example` and adjust values to suit your own environment.
```bash
cp .env.example .env
```
To know the details, you can read the documentation. [Jump to document section](#documentation)
### Database Migration
> The migration will do data seeding to MongoDB. Make sure to check the value of the `DATABASE_` prefix in your`.env` file.
The Database migration used [NestJs-Command][ref-nestjscommand]
For seeding
```bash
yarn seed
```
For remove all data do
```bash
yarn rollback
```
### Test
> The test is still not good net. I'm still lazy too do that.
The project provide 3 automation testing `unit testing`, `integration testing`, and `e2e testing`.
```bash
yarn test
```
For specific test do this
* Unit testing
```bash
yarn test:unit
```
* Integration testing
```bash
yarn test:integration
```
* E2E testing
```bash
yarn test:e2e
```
### Run Project
Finally, Cheers 🍻🍻 !!! you passed all steps.
Now you can run the project.
```bash
yarn start:dev
```
### Run Project with Docker
For docker installation, we need more tools to be installed in our instance.
1. [Docker][ref-docker]
2. [Docker-Compose][ref-dockercompose]
Then run
```bash
docker-compose up -d
```
## API Reference
You can check The ApiSpec after running this project. [here][api-reference-docs]
## Documentation
> Ongoing update
## Adjust Mongoose Setting
> Optional, if your mongodb version is < 5
Go to file `src/common/database/services/database.options.service.ts` and add `useMongoClient` to `mongooseOptions` then set value to `true`.
```typescript
const mongooseOptions: MongooseModuleOptions = {
uri,
useNewUrlParser: true,
useUnifiedTopology: true,
serverSelectionTimeoutMS: 5000,
useMongoClient: true // <--- add this
};
```
## License
Distributed under [MIT licensed][license].
## Contribute
How to contribute in this repo
1. Fork the project with click `Fork` button of this repo.
2. Clone the fork project
```bash
git clone "url you just copied"
```
3. Make necessary changes and commit those changes
4. Commit the changes
```bash
git commit -m "your message"
```
5. Push changes to fork project
```bash
git push origin -u main
```
6. Back to browser, goto your fork repo github. Then, click `Compare & pull request`
If your code behind commit with the original, please update your code and resolve the conflict. Then, repeat from number 6.
### Rule
* Avoid Circular Dependency
* Consume component folder structure, and repository design pattern
* Always make `service` for every module is independently.
* Do not put `controller` into service modules, cause this will break the dependency. Only put the controller into `router` and then inject the dependency.
* Put the config in `/configs` folder, and for dynamic config put as `environment variable`
* `CommonModule` only for main package, and put the module that related of service/project into `/src/modules`. So, if we want to clear the unnecessary module, we just need to delete the `src/modules/**`
* If there a new service in CommonModule. Make sure to create the unit test in `/test/unit`.
* If there a new controller, make sure to create the e2e testing in `test/e2e`
## Contact
[Fedi DAYEG][author-email]
[![Github][github-shield]][author-github]
[![LinkedIn][linkedin-shield]][author-linkedin]
<!-- BADGE LINKS -->
[nest-contributors-shield]: https://img.shields.io/github/contributors/fedi-dayeg/complete-nestjs-boilerplate?style=for-the-badge
[nest-forks-shield]: https://img.shields.io/github/forks/fedi-dayeg/complete-nestjs-boilerplate?style=for-the-badge
[nest-stars-shield]: https://img.shields.io/github/stars/fedi-dayeg/complete-nestjs-boilerplate?style=for-the-badge
[nest-issues-shield]: https://img.shields.io/github/issues/fedi-dayeg/complete-nestjs-boilerplate?style=for-the-badge
[nest-license-shield]: https://img.shields.io/github/license/fedi-dayeg/complete-nestjs-boilerplate?style=for-the-badge
[nestjs-shield]: https://img.shields.io/badge/nestjs-%23E0234E.svg?style=for-the-badge&logo=nestjs&logoColor=white
[nodejs-shield]: https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white
[typescript-shield]: https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white
[mongodb-shield]: https://img.shields.io/badge/MongoDB-white?style=for-the-badge&logo=mongodb&logoColor=4EA94B
[jwt-shield]: https://img.shields.io/badge/JWT-000000?style=for-the-badge&logo=JSON%20web%20tokens&logoColor=white
[jest-shield]: https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white
[yarn-shield]: https://img.shields.io/badge/yarn-%232C8EBB.svg?style=for-the-badge&logo=yarn&logoColor=white
[docker-shield]: https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white
[github-shield]: https://img.shields.io/badge/GitHub-100000?style=for-the-badge&logo=github&logoColor=white
[linkedin-shield]: https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white
<!-- CONTACTS -->
[author-linkedin]: https://www.linkedin.com/in/fedi-dayeg-a330b6131/
[author-email]: mailto:fedi.dayeg@gmail.com
[author-github]: https://github.com/fedi-dayeg
<!-- Repo LINKS -->
[nest-issues]: https://github.com/fedi-dayeg/complete-nestjs-boilerplate/issues
[nest-stars]: https://github.com/fedi-dayeg/complete-nestjs-boilerplate/stargazers
[nest-forks]: https://github.com/fedi-dayeg/complete-nestjs-boilerplate/network/members
[nest-contributors]: https://github.com/fedi-dayeg/complete-nestjs-boilerplate/graphs/contributors
<!-- Other Repo Links -->
[nest]: https://github.com/fedi-dayeg/complete-nestjs-boilerplate
<!-- license -->
[license]: LICENSE.md
<!-- Reference -->
[ref-nestjs]: http://nestjs.com
[ref-mongoose]: https://mongoosejs.com
[ref-mongodb]: https://docs.mongodb.com/
[ref-nodejs]: https://nodejs.org/
[ref-typescript]: https://www.typescriptlang.org/
[ref-docker]: https://docs.docker.com
[ref-dockercompose]: https://docs.docker.com/compose/
[ref-yarn]: https://yarnpkg.com
[ref-12factor]: https://12factor.net
[ref-nestjscommand]: https://gitlab.com/aa900031/nestjs-command
[ref-jwt]: https://jwt.io
[ref-jest]: https://jestjs.io/docs/getting-started
[ref-git]: https://git-scm.com
<!-- API Reference -->
[api-reference-docs]: http://localhost:3000/docs

60
cspell.json Normal file
View File

@ -0,0 +1,60 @@
{
"version": "0.2",
"language": "en",
"words": [
"nestjs",
"metatype",
"virtuals",
"prebuild",
"logform",
"transfomer",
"insync",
"microservices",
"globby",
"dockerhub",
"fifsky",
"buildx",
"exceljs",
"milis",
"workdir",
"dbdata",
"initdb",
"deadcode",
"authapis",
"headerapikey",
"jenkinsfile",
"superadmin",
"alphanum",
"dtos",
"typeorm",
"apikeys",
"apikey",
"maxlength",
"Streamable",
"unallow",
"datauser"
],
"ignoreWords": [
"psheon",
"aallithioo",
"tiaamoo",
"qwertyuiop12345zxcvbnmkjh",
"opbUwdiS1FBsrDUoPgZdx",
"cuwakimacojulawu",
"baibay",
"acks",
"andrechristikan",
"trueaaa",
"aasdasd"
],
"ignorePaths": [
"node_modules/**",
"endpoints/**",
"*coverage/**",
".husky/**",
".github/**",
"dist/**",
"logs/**",
"src/database/json/**"
]
}

37
docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
version: '3.8'
services:
nestService:
build: .
container_name: nestService
hostname: nestService
ports:
- 3000:3000
networks:
- app-network
volumes:
- ./src/:/app/src/
- .env/:/app/.env
restart: unless-stopped
depends_on:
- nestDatabase
nestDatabase:
image: mongo:latest
container_name: nestDatabase
hostname: nestDatabase
ports:
- 27017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: nest
volumes:
- dbdata:/data/db
restart: unless-stopped
networks:
- app-network
networks:
app-network:
name: app-network
driver: bridge
volumes:
dbdata:

14
dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:lts-alpine
LABEL maintainer "fedi.dayeg@gmail.com"
WORKDIR /app
EXPOSE 3000
COPY package.json yarn.lock ./
RUN touch .env
RUN set -x && yarn
COPY . .
CMD [ "yarn", "start:dev" ]

16
nest-cli.json Normal file
View File

@ -0,0 +1,16 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger"],
"assets": [
{
"include": "languages/**/*",
"outDir": "dist/src"
}
],
"webpack": false,
"deleteOutDir": true,
"watchAssets": false
}
}

View File

@ -1,45 +1,135 @@
{ {
"name": "@plastichub/template", "name": "nestjs-boilerplate",
"description": "", "version": "1.0.0",
"version": "0.3.1", "description": "NestJs Boilerplate",
"main": "main.js", "repository": {
"typings": "index.d.ts", "type": "git"
"publishConfig": {
"access": "public"
}, },
"bin": { "author": {
"osr-bin": "main.js" "name": "Fedi DAYEG",
"email": "fedi.dayeg@gmail.com"
},
"private": true,
"license": "MIT",
"scripts": {
"upgrade:package": "ncu -u",
"prebuild": "rimraf dist",
"build": "nest build",
"format": "yarn format:src && yarn format:test",
"format:src": "prettier --write src/**/*.ts",
"format:test": "prettier --write test/**/*.ts",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
"lint": "yarn lint:src && yarn lint:test",
"lint:fix": "eslint --ext .ts,.tsx '{src,test}/**/*.ts' --fix --no-error-on-unmatched-pattern",
"lint:src": "eslint --ext .ts,.tsx 'src/**/*.ts' --no-error-on-unmatched-pattern",
"lint:test": "eslint --ext .ts,.tsx 'test/**/*.ts' --no-error-on-unmatched-pattern",
"test": "yarn test:unit && yarn test:integration && yarn test:e2e",
"test:unit": "jest --config test/unit/jest.json --passWithNoTests --forceExit",
"test:integration": "jest --config test/integration/jest.json --passWithNoTests --forceExit",
"test:e2e": "jest --runInBand --config test/e2e/jest.json --verbose --passWithNoTests --forceExit",
"prepare": "husky install",
"deadcode": "ts-prune --project tsconfig.json --skip *.json",
"deadcode:filter": "ts-prune --project tsconfig.json --skip *.json | grep -v '(used in module)'",
"deadcode:count": "ts-prune --project tsconfig.json --skip *.json | grep -v '(used in module)' | wc -l",
"spell": "yarn spell:src && yarn spell:test",
"spell:src": "cspell lint --config cspell.json src/**/*.ts --color --gitignore --no-must-find-files --no-summary --no-progress || true",
"spell:test": "cspell lint --config cspell.json test/**/*.ts --color --gitignore --no-must-find-files --no-summary --no-progress || true",
"seed:setting": "nestjs-command seed:setting",
"seed:apikey": "nestjs-command seed:apikey",
"seed:permission": "nestjs-command seed:permission",
"seed:role": "nestjs-command seed:role",
"seed:user": "nestjs-command seed:user",
"rollback:setting": "nestjs-command remove:setting",
"rollback:apikey": "nestjs-command remove:apikey",
"rollback:permission": "nestjs-command remove:permission",
"rollback:role": "nestjs-command remove:role",
"rollback:user": "nestjs-command remove:user",
"seed": "yarn seed:setting && yarn seed:permission && yarn seed:role && yarn seed:user && yarn seed:apikey",
"rollback": "yarn rollback:setting && yarn rollback:apikey && yarn rollback:user && yarn rollback:role && yarn rollback:permission"
}, },
"dependencies": { "dependencies": {
"@types/node": "^14.17.5", "@aws-sdk/client-s3": "^3.292.0",
"@types/yargs": "^17.0.2", "@faker-js/faker": "^7.6.0",
"chalk": "^2.4.1", "@joi/date": "^2.1.0",
"convert-units": "^2.3.4", "@nestjs/axios": "^2.0.0",
"env-var": "^7.0.1", "@nestjs/common": "^9.3.10",
"typescript": "^4.3.5", "@nestjs/config": "^2.3.1",
"yargs": "^14.2.3", "@nestjs/core": "^9.3.10",
"yargs-parser": "^15.0.3" "@nestjs/jwt": "^10.0.2",
"@nestjs/mongoose": "^9.2.1",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.3.10",
"@nestjs/schedule": "^2.2.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/terminus": "^9.2.1",
"@nestjs/throttler": "^4.0.0",
"@types/response-time": "^2.3.5",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"crypto-js": "^4.1.1",
"geolib": "^3.3.3",
"helmet": "^6.0.1",
"joi": "^17.8.4",
"moment": "^2.29.4",
"mongoose": "^7.0.2",
"morgan": "^1.10.0",
"nest-winston": "^1.9.1",
"nestjs-command": "^3.1.3",
"nestjs-i18n": "^10.2.6",
"passport": "^0.6.0",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"response-time": "^2.3.2",
"rimraf": "^4.4.0",
"rotating-file-stream": "^3.1.0",
"rxjs": "^7.8.0",
"ua-parser-js": "^1.0.34",
"winston": "^3.8.2",
"winston-daily-rotate-file": "^4.7.1",
"xlsx": "^0.18.5",
"yargs": "^17.7.1",
"yarn": "^1.22.19"
}, },
"scripts": { "devDependencies": {
"test": "tsc; mocha --full-trace mocha \"spec/**/*.spec.js\"", "@nestjs/cli": "^9.2.0",
"test-with-coverage": "istanbul cover node_modules/.bin/_mocha -- 'spec/**/*.spec.js'", "@nestjs/schematics": "^9.0.4",
"lint": "tslint --project=./tsconfig.json", "@nestjs/testing": "^9.3.10",
"build": "tsc -p .", "@types/bcryptjs": "^2.4.2",
"dev": "tsc -p . --declaration -w", "@types/bytes": "^3.1.1",
"typings": "tsc --declaration", "@types/cors": "^2.8.13",
"docs": "npx typedoc src/index.ts", "@types/cron": "^2.0.0",
"dev-test-watch": "mocha-typescript-watch" "@types/crypto-js": "^4.1.1",
}, "@types/express": "^4.17.17",
"homepage": "https://git.osr-plastic.org/plastichub/lib-content", "@types/jest": "^29.4.4",
"repository": { "@types/lodash": "^4.14.191",
"type": "git", "@types/morgan": "^1.9.4",
"url": "https://git.osr-plastic.org/plastichub/lib-content.git" "@types/ms": "^0.7.31",
}, "@types/multer": "^1.4.7",
"engines": { "@types/node": "^18.15.3",
"node": ">= 14.0.0" "@types/passport-jwt": "^3.0.8",
}, "@types/supertest": "^2.0.12",
"license": "BSD-3-Clause", "@types/ua-parser-js": "^0.7.36",
"keywords": [ "@types/uuid": "^9.0.1",
"typescript" "@typescript-eslint/eslint-plugin": "^5.55.0",
] "@typescript-eslint/parser": "^5.55.0",
"cspell": "^6.30.0",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-import": "^2.27.5",
"husky": "^8.0.3",
"jest": "^29.5.0",
"prettier": "^2.8.4",
"supertest": "^6.3.3",
"ts-jest": "^29.0.5",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"ts-prune": "^0.10.3",
"tsconfig-paths": "^4.1.2",
"typescript": "^5.0.2"
}
} }

View File

@ -0,0 +1,31 @@
# Test Image
FROM node:lts-alpine as builder
LABEL maintainer "fedi.dayeg@gmail.com"
WORKDIR /app
COPY package.json yarn.lock ./
RUN set -x && yarn --production=false
COPY . .
RUN yarn build
# Production Image
FROM node:lts-alpine as main
LABEL maintainer "fedi.dayeg@gmail.com"
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /app
EXPOSE 3000
COPY package.json yarn.lock ./
RUN touch .env
RUN set -x && yarn --production=true
COPY --from=builder /app/dist ./dist
CMD ["yarn", "start:prod"]

193
scripts/jenkinsfile Normal file
View File

@ -0,0 +1,193 @@
def commit_id
pipeline {
agent any
options {
skipDefaultCheckout true
}
environment {
BASE_IMAGE='node:lts-alpine' // change this with same version of container image
APP_NAME = 'nest' // this will be container name
APP_NETWORK = 'app-network' // this network name
APP_PORT = 3000 // this project will serve in port 0.0.0.0:3000
APP_EXPOSE_PORT = 3000 // this project will expose at port 0.0.0.0:3000
NODE_JS = 'lts' // depends with our jenkins setting
HOST_IP = 'xx.xx.xx.xx' // this server ip for your production server
HOST_CREDENTIAL = '2637b88f-8dc8-4395-bd6b-0c6127720a89' // depends with our credentials jenkins
DOCKER_CREDENTIAL = 'ef108994-1241-4614-aab5-2aadd7a72284' // depends with our credentials jenkins
DOCKER_FILE= './prod/dockerfile'
DOCKER_USERNAME = 'nest' // docker hub username
DOCKER_REGISTRY = 'https://index.docker.io/v1/'
GIT = 'Default' // depends with our jenkins setting
GIT_BRANCH = 'main' // git branch
GIT_CREDENTIAL = '86535ad6-5d74-48c0-9852-bddbe1fbaff6' // depends with our credentials jenkins
GIT_URL = 'git@github.com:fedi-dayeg/nestjs-mongoose.git'
}
tools {
nodejs NODE_JS
git GIT
}
stages {
stage('Prepare') {
steps {
cleanWs()
checkout scm
sh 'node --version && npm --version && yarn --version'
sh 'docker --version'
sh 'docker ps -a'
script{
def nodeContainer = docker.image(BASE_IMAGE)
nodeContainer.pull()
nodeContainer.inside {
sh 'node --version'
sh 'npm --version'
sh 'yarn --version'
}
}
}
}
stage('Clone') {
steps {
git branch: GIT_BRANCH,
credentialsId: GIT_CREDENTIAL,
url: GIT_URL
sh "git rev-parse --short HEAD > .git/commit-id"
sh "grep -o '\"version\": \"[^\"]*' package.json | grep -o '[^\"]*\$' > .git/version-id"
script{
commit_id = readFile('.git/commit-id').trim()
}
}
}
stage('Build'){
steps{
script{
def app_image = "${DOCKER_USERNAME}/${APP_NAME}-builder:${commit_id}"
docker.build(app_image, "--target builder -f ${DOCKER_FILE} .")
}
}
}
stage('Unit Test') {
steps {
script{
def app_image = "${DOCKER_USERNAME}/${APP_NAME}-builder:${commit_id}"
def container = "${APP_NAME}-testing"
try{
sh "docker stop ${container} && docker rm ${container}"
}catch(e){}
try{
sh "docker network create ${APP_NETWORK} --driver=bridge"
}catch(e){}
sh "docker run --rm --network ${APP_NETWORK} \
--volume /app/${APP_NAME}/.env:/app/.env \
--name ${container} \
${app_image} \
sh -c 'yarn test:unit'"
}
}
}
stage('Push') {
steps {
script{
def version_id = readFile('.git/version-id').trim()
def app_image = "${DOCKER_USERNAME}/${APP_NAME}:${commit_id}"
def app = docker.build(app_image, "--target main -f ${DOCKER_FILE} .")
docker.withRegistry(DOCKER_REGISTRY, DOCKER_CREDENTIAL) {
app.push('latest')
app.push("v${version_id}")
app.push("v${version_id}_sha-${commit_id}")
}
}
}
}
stage('Deploy') {
steps {
script{
def version_id = readFile('.git/version-id').trim()
def app_image = "${DOCKER_USERNAME}/${APP_NAME}:v${version_id}_sha-${commit_id}"
def remote = [:]
remote.name = APP_NAME
remote.host = HOST_IP
remote.allowAnyHosts = true
withCredentials([sshUserPrivateKey(credentialsId: HOST_CREDENTIAL, keyFileVariable: 'IDENTITY', usernameVariable: 'USERNAME')]) {
remote.user = USERNAME
remote.identityFile = IDENTITY
try{
sshCommand remote: remote, command: "docker stop ${APP_NAME} && docker rm ${APP_NAME}"
}catch(e){}
try{
sshCommand remote: remote, command: "docker network create ${APP_NETWORK} --driver=bridge"
}catch(e){}
sshCommand remote: remote, command: "docker run -itd \
--hostname ${APP_NAME} \
--publish ${APP_EXPOSE_PORT}:${APP_PORT} \
--network ${APP_NETWORK} \
--volume /app/${APP_NAME}/logs/:/app/logs/ \
--volume /app/${APP_NAME}/.env:/app/.env \
--restart unless-stopped \
--name ${APP_NAME} ${app_image}"
}
}
}
}
stage('Clean'){
steps {
script{
def remote = [:]
remote.name = APP_NAME
remote.host = HOST_IP
remote.allowAnyHosts = true
withCredentials([sshUserPrivateKey(credentialsId: HOST_CREDENTIAL, keyFileVariable: 'IDENTITY', usernameVariable: 'USERNAME')]) {
remote.user = USERNAME
remote.identityFile = IDENTITY
try{
sshCommand remote: remote, command: "docker container prune --force"
}catch(e){}
try{
sshCommand remote: remote, command: "docker image prune --force"
}catch(e){}
try{
sshCommand remote: remote, command: "docker rmi \$(docker images **/${APP_NAME}** -q) --force"
}catch(e){}
}
try{
sh "docker container prune --force"
}catch(e){}
try{
sh "docker image prune --force"
}catch(e){}
try{
sh "docker rmi \$(docker images **/${APP_NAME}** -q) --force"
}catch(e){}
}
}
}
}
}

0
src/.gitignore vendored
View File

20
src/app/app.module.ts Normal file
View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { JobsModule } from 'src/jobs/jobs.module';
import { AppController } from './controllers/app.controller';
import { RouterModule } from 'src/router/router.module';
import { CommonModule } from 'src/common/common.module';
@Module({
controllers: [AppController],
providers: [],
imports: [
CommonModule,
// Jobs
JobsModule.forRoot(),
// Routes
RouterModule.forRoot(),
],
})
export class AppModule {}

View File

@ -0,0 +1 @@
export const APP_LANGUAGE = 'en';

View File

@ -0,0 +1,5 @@
export enum ENUM_APP_ENVIRONMENT {
PRODUCTION = 'production',
STAGING = 'staging',
DEVELOPMENT = 'development',
}

View File

@ -0,0 +1,81 @@
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags } from '@nestjs/swagger';
import { AppHelloApiKeyDoc, AppHelloDoc } from 'src/app/docs/app.doc';
import { AppHelloSerialization } from 'src/app/serializations/app.hello.serialization';
import { ApiKeyProtected } from 'src/common/api-key/decorators/api-key.decorator';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
import { ENUM_LOGGER_ACTION } from 'src/common/logger/constants/logger.enum.constant';
import { Logger } from 'src/common/logger/decorators/logger.decorator';
import { RequestUserAgent } from 'src/common/request/decorators/request.decorator';
import { Response } from 'src/common/response/decorators/response.decorator';
import { IResponse } from 'src/common/response/interfaces/response.interface';
import { IResult } from 'ua-parser-js';
@ApiTags('hello')
@Controller({
version: VERSION_NEUTRAL,
path: '/',
})
export class AppController {
private readonly serviceName: string;
constructor(
private readonly configService: ConfigService,
private readonly helperDateService: HelperDateService
) {
this.serviceName = this.configService.get<string>('app.name');
}
@AppHelloDoc()
@Response('app.hello', { serialization: AppHelloSerialization })
@Logger(ENUM_LOGGER_ACTION.TEST, { tags: ['test'] })
@Get('/hello')
async hello(@RequestUserAgent() userAgent: IResult): Promise<IResponse> {
const newDate = this.helperDateService.create();
return {
_metadata: {
customProperty: {
messageProperties: {
serviceName: this.serviceName
},
},
},
data: {
userAgent,
date: newDate,
format: this.helperDateService.format(newDate),
timestamp: this.helperDateService.timestamp(newDate),
},
};
}
@AppHelloApiKeyDoc()
@Response('app.hello', { serialization: AppHelloSerialization })
@Logger(ENUM_LOGGER_ACTION.TEST, { tags: ['test'] })
@ApiKeyProtected()
@Get('/hello/api-key')
async helloApiKey(
@RequestUserAgent() userAgent: IResult
): Promise<IResponse> {
const newDate = this.helperDateService.create();
return {
_metadata: {
customProperty: {
messageProperties: {
serviceName: this.serviceName
}
},
},
data: {
userAgent,
date: newDate,
format: this.helperDateService.format(newDate),
timestamp: this.helperDateService.timestamp(newDate),
},
};
}
}

30
src/app/docs/app.doc.ts Normal file
View File

@ -0,0 +1,30 @@
import { applyDecorators } from '@nestjs/common';
import { AppHelloSerialization } from 'src/app/serializations/app.hello.serialization';
import { Doc } from 'src/common/doc/decorators/doc.decorator';
export function AppHelloDoc(): MethodDecorator {
return applyDecorators(
Doc<AppHelloSerialization>('app.hello', {
response: {
serialization: AppHelloSerialization,
},
})
);
}
export function AppHelloApiKeyDoc(): MethodDecorator {
return applyDecorators(
Doc<AppHelloSerialization>('app.helloApiKey', {
auth: {
apiKey: true,
},
requestHeader: {
timestamp: true,
userAgent: true,
},
response: {
serialization: AppHelloSerialization,
},
})
);
}

View File

@ -0,0 +1,30 @@
import { faker } from '@faker-js/faker';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IResult } from 'ua-parser-js';
export class AppHelloSerialization {
@ApiProperty({
example: {
ua: 'PostmanRuntime/7.29.0',
browser: {},
engine: {},
os: {},
device: {},
cpu: {},
},
})
readonly userAgent: IResult;
@ApiProperty({ example: faker.date.recent() })
@Type(() => String)
readonly date: Date;
@ApiProperty({ example: faker.date.recent() })
readonly format: string;
@ApiProperty({
example: 1660190937231,
})
readonly timestamp: number;
}

22
src/cli.ts Normal file
View File

@ -0,0 +1,22 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { CommandModule, CommandService } from 'nestjs-command';
import { MigrationModule } from './migration/migration.module';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(MigrationModule, {
logger: ['error'],
});
const logger = new Logger();
try {
await app.select(CommandModule).get(CommandService).exec();
process.exit(0);
} catch (err: unknown) {
logger.error(err, 'Migration');
process.exit(1);
}
}
bootstrap();

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ApiKeyXApiKeyStrategy } from 'src/common/api-key/guards/x-api-key/api-key.x-api-key.strategy';
import { ApiKeyRepositoryModule } from 'src/common/api-key/repository/api-key.repository.module';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
@Module({
providers: [ApiKeyService, ApiKeyXApiKeyStrategy],
exports: [ApiKeyService],
controllers: [],
imports: [ApiKeyRepositoryModule],
})
export class ApiKeyModule {}

View File

@ -0,0 +1 @@
export const API_KEY_ACTIVE_META_KEY = 'ApiKeyActiveMetaKey';

View File

@ -0,0 +1,22 @@
import { faker } from '@faker-js/faker';
export const ApiKeyDocQueryIsActive = [
{
name: 'isActive',
allowEmptyValue: false,
required: true,
type: 'string',
example: 'true,false',
description: "boolean value with ',' delimiter",
},
];
export const ApiKeyDocParamsGet = [
{
name: 'apiKey',
allowEmptyValue: false,
required: true,
type: 'string',
example: faker.datatype.uuid(),
},
];

View File

@ -0,0 +1,9 @@
import { ENUM_PAGINATION_ORDER_DIRECTION_TYPE } from 'src/common/pagination/constants/pagination.enum.constant';
export const API_KEY_DEFAULT_PER_PAGE = 20;
export const API_KEY_DEFAULT_ORDER_BY = 'createdAt';
export const API_KEY_DEFAULT_ORDER_DIRECTION =
ENUM_PAGINATION_ORDER_DIRECTION_TYPE.ASC;
export const API_KEY_DEFAULT_AVAILABLE_ORDER_BY = ['name', 'key', 'createdAt'];
export const API_KEY_DEFAULT_AVAILABLE_SEARCH = ['name', 'key'];
export const API_KEY_DEFAULT_IS_ACTIVE = [true, false];

View File

@ -0,0 +1,10 @@
export enum ENUM_API_KEY_STATUS_CODE_ERROR {
API_KEY_NEEDED_ERROR = 5120,
API_KEY_NOT_FOUND_ERROR = 5121,
API_KEY_IS_ACTIVE_ERROR = 5122,
API_KEY_EXPIRED_ERROR = 5123,
API_KEY_INACTIVE_ERROR = 5124,
API_KEY_INVALID_ERROR = 5125,
API_KEY_WRONG_ERROR = 5126,
API_KEY_EXIST_ERROR = 5127,
}

View File

@ -0,0 +1,118 @@
import {
Body,
Controller,
InternalServerErrorException,
Patch,
Put,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
ApiKeyUpdateActiveGuard,
ApiKeyUpdateGuard,
ApiKeyUpdateInactiveGuard,
} from 'src/common/api-key/decorators/api-key.admin.decorator';
import { GetApiKey } from 'src/common/api-key/decorators/api-key.decorator';
import {
ApiKeyActiveDoc,
ApiKeyInactiveDoc,
ApiKeyUpdateDoc,
} from 'src/common/api-key/docs/api-key.admin.doc';
import { ApiKeyRequestDto } from 'src/common/api-key/dtos/api-key.request.dto';
import { ApiKeyUpdateDateDto } from 'src/common/api-key/dtos/api-key.update-date.dto';
import { ApiKeyDoc } from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
import { ENUM_AUTH_PERMISSIONS } from 'src/common/auth/constants/auth.enum.permission.constant';
import { AuthJwtAccessProtected } from 'src/common/auth/decorators/auth.jwt.decorator';
import { AuthPermissionProtected } from 'src/common/auth/decorators/auth.permission.decorator';
import { ENUM_ERROR_STATUS_CODE_ERROR } from 'src/common/error/constants/error.status-code.constant';
import { RequestParamGuard } from 'src/common/request/decorators/request.decorator';
import { Response } from 'src/common/response/decorators/response.decorator';
import { IResponse } from 'src/common/response/interfaces/response.interface';
import { ResponseIdSerialization } from 'src/common/response/serializations/response.id.serialization';
@ApiTags('admin.apiKey')
@Controller({
version: '1',
path: '/api-key',
})
export class ApiKeyAdminController {
constructor(private readonly apiKeyService: ApiKeyService) {}
@ApiKeyInactiveDoc()
@Response('apiKey.inactive')
@ApiKeyUpdateInactiveGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthPermissionProtected(
ENUM_AUTH_PERMISSIONS.API_KEY_READ,
ENUM_AUTH_PERMISSIONS.API_KEY_UPDATE,
ENUM_AUTH_PERMISSIONS.API_KEY_INACTIVE
)
@AuthJwtAccessProtected()
@Patch('/update/:apiKey/inactive')
async inactive(@GetApiKey() apiKey: ApiKeyDoc): Promise<void> {
try {
await this.apiKeyService.inactive(apiKey);
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
return;
}
@ApiKeyActiveDoc()
@Response('apiKey.active')
@ApiKeyUpdateActiveGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthPermissionProtected(
ENUM_AUTH_PERMISSIONS.API_KEY_READ,
ENUM_AUTH_PERMISSIONS.API_KEY_UPDATE,
ENUM_AUTH_PERMISSIONS.API_KEY_ACTIVE
)
@AuthJwtAccessProtected()
@Patch('/update/:apiKey/active')
async active(@GetApiKey() apiKey: ApiKeyDoc): Promise<void> {
try {
await this.apiKeyService.active(apiKey);
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
return;
}
@ApiKeyUpdateDoc()
@Response('apiKey.updateDate', { serialization: ResponseIdSerialization })
@ApiKeyUpdateGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthPermissionProtected(
ENUM_AUTH_PERMISSIONS.API_KEY_READ,
ENUM_AUTH_PERMISSIONS.API_KEY_UPDATE,
ENUM_AUTH_PERMISSIONS.API_KEY_UPDATE_DATE
)
@AuthJwtAccessProtected()
@Put('/update/:apiKey/date')
async updateDate(
@Body() body: ApiKeyUpdateDateDto,
@GetApiKey() apiKey: ApiKeyDoc
): Promise<IResponse> {
try {
await this.apiKeyService.updateDate(apiKey, body);
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
return { data: { _id: apiKey._id } };
}
}

View File

@ -0,0 +1,222 @@
import {
Body,
ConflictException,
Controller,
Get,
InternalServerErrorException,
Patch,
Post,
Put,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
API_KEY_DEFAULT_AVAILABLE_ORDER_BY,
API_KEY_DEFAULT_AVAILABLE_SEARCH,
API_KEY_DEFAULT_IS_ACTIVE,
API_KEY_DEFAULT_ORDER_BY,
API_KEY_DEFAULT_ORDER_DIRECTION,
API_KEY_DEFAULT_PER_PAGE,
} from 'src/common/api-key/constants/api-key.list.constant';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
import {
ApiKeyGetGuard,
ApiKeyUpdateGuard,
ApiKeyUpdateResetGuard,
} from 'src/common/api-key/decorators/api-key.admin.decorator';
import { GetApiKey } from 'src/common/api-key/decorators/api-key.decorator';
import {
ApiKeyCreateDoc,
ApiKeyGetDoc,
ApiKeyListDoc,
ApiKeyResetDoc,
ApiKeyUpdateDoc,
} from 'src/common/api-key/docs/api-key.admin.doc';
import { ApiKeyCreateDto } from 'src/common/api-key/dtos/api-key.create.dto';
import { ApiKeyRequestDto } from 'src/common/api-key/dtos/api-key.request.dto';
import { ApiKeyUpdateDto } from 'src/common/api-key/dtos/api-key.update.dto';
import { IApiKeyCreated } from 'src/common/api-key/interfaces/api-key.interface';
import {
ApiKeyDoc,
ApiKeyEntity,
} from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyCreateSerialization } from 'src/common/api-key/serializations/api-key.create.serialization';
import { ApiKeyGetSerialization } from 'src/common/api-key/serializations/api-key.get.serialization';
import { ApiKeyListSerialization } from 'src/common/api-key/serializations/api-key.list.serialization';
import { ApiKeyResetSerialization } from 'src/common/api-key/serializations/api-key.reset.serialization';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
import {
AuthJwtAccessProtected,
AuthJwtPayload,
} from 'src/common/auth/decorators/auth.jwt.decorator';
import { ENUM_ERROR_STATUS_CODE_ERROR } from 'src/common/error/constants/error.status-code.constant';
import {
PaginationQuery,
PaginationQueryFilterInBoolean,
} from 'src/common/pagination/decorators/pagination.decorator';
import { PaginationListDto } from 'src/common/pagination/dtos/pagination.list.dto';
import { PaginationService } from 'src/common/pagination/services/pagination.service';
import { RequestParamGuard } from 'src/common/request/decorators/request.decorator';
import {
Response,
ResponsePaging,
} from 'src/common/response/decorators/response.decorator';
import {
IResponse,
IResponsePaging,
} from 'src/common/response/interfaces/response.interface';
import { ResponseIdSerialization } from 'src/common/response/serializations/response.id.serialization';
@ApiTags('apiKey')
@Controller({
version: '1',
path: '/api-key',
})
export class ApiKeyController {
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly paginationService: PaginationService
) {}
@ApiKeyListDoc()
@ResponsePaging('apiKey.list', {
serialization: ApiKeyListSerialization,
})
@AuthJwtAccessProtected()
@Get('/list')
async list(
@PaginationQuery(
API_KEY_DEFAULT_PER_PAGE,
API_KEY_DEFAULT_ORDER_BY,
API_KEY_DEFAULT_ORDER_DIRECTION,
API_KEY_DEFAULT_AVAILABLE_SEARCH,
API_KEY_DEFAULT_AVAILABLE_ORDER_BY
)
{ _search, _limit, _offset, _order }: PaginationListDto,
@PaginationQueryFilterInBoolean('isActive', API_KEY_DEFAULT_IS_ACTIVE)
isActive: Record<string, any>
): Promise<IResponsePaging> {
const find: Record<string, any> = {
..._search,
...isActive,
};
const apiKeys: ApiKeyEntity[] = await this.apiKeyService.findAll(find, {
paging: {
limit: _limit,
offset: _offset,
},
order: _order,
});
const total: number = await this.apiKeyService.getTotal(find);
const totalPage: number = this.paginationService.totalPage(
total,
_limit
);
return {
_pagination: { totalPage, total },
data: apiKeys,
};
}
@ApiKeyGetDoc()
@Response('apiKey.get', {
serialization: ApiKeyGetSerialization,
})
@ApiKeyGetGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthJwtAccessProtected()
@Get('get/:apiKey')
async get(@GetApiKey(true) apiKey: ApiKeyEntity): Promise<IResponse> {
return { data: apiKey };
}
@ApiKeyCreateDoc()
@Response('apiKey.create', { serialization: ApiKeyCreateSerialization })
@AuthJwtAccessProtected()
@Post('/create')
async create(
@AuthJwtPayload('_id') _id: string,
@Body() body: ApiKeyCreateDto
): Promise<IResponse> {
const checkUser: boolean = await this.apiKeyService.existByUser(_id);
if (checkUser) {
throw new ConflictException({
statusCode: ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_EXIST_ERROR,
message: 'apiKey.error.exist',
});
}
try {
const created: IApiKeyCreated = await this.apiKeyService.create(
_id,
body
);
return {
data: {
_id: created.doc._id,
secret: created.secret,
},
};
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
}
@ApiKeyResetDoc()
@Response('apiKey.reset', { serialization: ApiKeyResetSerialization })
@ApiKeyUpdateResetGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthJwtAccessProtected()
@Patch('/update/:apiKey/reset')
async reset(@GetApiKey() apiKey: ApiKeyDoc): Promise<IResponse> {
try {
const secret: string = await this.apiKeyService.createSecret();
const updated: ApiKeyDoc = await this.apiKeyService.reset(
apiKey,
secret
);
return {
data: {
_id: updated._id,
secret,
},
};
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
}
@ApiKeyUpdateDoc()
@Response('apiKey.update', { serialization: ResponseIdSerialization })
@ApiKeyUpdateGuard()
@RequestParamGuard(ApiKeyRequestDto)
@AuthJwtAccessProtected()
@Put('/update/:apiKey')
async updateName(
@Body() body: ApiKeyUpdateDto,
@GetApiKey() apiKey: ApiKeyDoc
): Promise<IResponse> {
try {
await this.apiKeyService.update(apiKey, body);
} catch (err: any) {
throw new InternalServerErrorException({
statusCode: ENUM_ERROR_STATUS_CODE_ERROR.ERROR_UNKNOWN,
message: 'http.serverError.internalServerError',
_error: err.message,
});
}
return { data: { _id: apiKey._id } };
}
}

View File

@ -0,0 +1,60 @@
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { API_KEY_ACTIVE_META_KEY } from 'src/common/api-key/constants/api-key.constant';
import { ApiKeyActiveGuard } from 'src/common/api-key/guards/api-key.active.guard';
import { ApiKeyExpiredGuard } from 'src/common/api-key/guards/api-key.expired.guard';
import { ApiKeyNotFoundGuard } from 'src/common/api-key/guards/api-key.not-found.guard';
import { ApiKeyPutToRequestGuard } from 'src/common/api-key/guards/api-key.put-to-request.guard';
export function ApiKeyGetGuard(): MethodDecorator {
return applyDecorators(
UseGuards(ApiKeyPutToRequestGuard, ApiKeyNotFoundGuard)
);
}
export function ApiKeyUpdateGuard(): MethodDecorator {
return applyDecorators(
UseGuards(
ApiKeyPutToRequestGuard,
ApiKeyNotFoundGuard,
ApiKeyActiveGuard,
ApiKeyExpiredGuard
),
SetMetadata(API_KEY_ACTIVE_META_KEY, [true])
);
}
export function ApiKeyUpdateResetGuard(): MethodDecorator {
return applyDecorators(
UseGuards(
ApiKeyPutToRequestGuard,
ApiKeyNotFoundGuard,
ApiKeyActiveGuard,
ApiKeyExpiredGuard
),
SetMetadata(API_KEY_ACTIVE_META_KEY, [true])
);
}
export function ApiKeyUpdateActiveGuard(): MethodDecorator {
return applyDecorators(
UseGuards(
ApiKeyPutToRequestGuard,
ApiKeyNotFoundGuard,
ApiKeyActiveGuard,
ApiKeyExpiredGuard
),
SetMetadata(API_KEY_ACTIVE_META_KEY, [false])
);
}
export function ApiKeyUpdateInactiveGuard(): MethodDecorator {
return applyDecorators(
UseGuards(
ApiKeyPutToRequestGuard,
ApiKeyNotFoundGuard,
ApiKeyActiveGuard,
ApiKeyExpiredGuard
),
SetMetadata(API_KEY_ACTIVE_META_KEY, [true])
);
}

View File

@ -0,0 +1,27 @@
import {
applyDecorators,
createParamDecorator,
ExecutionContext,
UseGuards,
} from '@nestjs/common';
import { ApiKeyXApiKeyGuard } from 'src/common/api-key/guards/x-api-key/api-key.x-api-key.guard';
import { IApiKeyPayload } from 'src/common/api-key/interfaces/api-key.interface';
import { ApiKeyDoc } from 'src/common/api-key/repository/entities/api-key.entity';
export const ApiKeyPayload: () => ParameterDecorator = createParamDecorator(
(data: string, ctx: ExecutionContext): IApiKeyPayload => {
const { apiKey } = ctx.switchToHttp().getRequest();
return data ? apiKey[data] : apiKey;
}
);
export function ApiKeyProtected(): MethodDecorator {
return applyDecorators(UseGuards(ApiKeyXApiKeyGuard));
}
export const GetApiKey = createParamDecorator(
(returnPlain: boolean, ctx: ExecutionContext): ApiKeyDoc => {
const { __apiKey } = ctx.switchToHttp().getRequest();
return returnPlain ? __apiKey.toObject() : __apiKey;
}
);

View File

@ -0,0 +1,119 @@
import { applyDecorators, HttpStatus } from '@nestjs/common';
import {
ApiKeyDocParamsGet,
ApiKeyDocQueryIsActive,
} from 'src/common/api-key/constants/api-key.doc';
import { ApiKeyCreateSerialization } from 'src/common/api-key/serializations/api-key.create.serialization';
import { ApiKeyGetSerialization } from 'src/common/api-key/serializations/api-key.get.serialization';
import { ApiKeyListSerialization } from 'src/common/api-key/serializations/api-key.list.serialization';
import { Doc, DocPaging } from 'src/common/doc/decorators/doc.decorator';
import { ResponseIdSerialization } from 'src/common/response/serializations/response.id.serialization';
export function ApiKeyListDoc(): MethodDecorator {
return applyDecorators(
DocPaging<ApiKeyListSerialization>('apiKey.list', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
queries: ApiKeyDocQueryIsActive,
},
response: {
serialization: ApiKeyListSerialization,
},
})
);
}
export function ApiKeyGetDoc(): MethodDecorator {
return applyDecorators(
Doc<ApiKeyGetSerialization>('apiKey.get', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
params: ApiKeyDocParamsGet,
},
response: { serialization: ApiKeyGetSerialization },
})
);
}
export function ApiKeyCreateDoc(): MethodDecorator {
return applyDecorators(
Doc<ApiKeyCreateSerialization>('apiKey.create', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
response: {
httpStatus: HttpStatus.CREATED,
serialization: ApiKeyCreateSerialization,
},
})
);
}
export function ApiKeyActiveDoc(): MethodDecorator {
return applyDecorators(
Doc<void>('apiKey.active', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
params: ApiKeyDocParamsGet,
},
})
);
}
export function ApiKeyInactiveDoc(): MethodDecorator {
return applyDecorators(
Doc<void>('apiKey.inactive', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
params: ApiKeyDocParamsGet,
},
})
);
}
export function ApiKeyResetDoc(): MethodDecorator {
return applyDecorators(
Doc<void>('apiKey.reset', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
params: ApiKeyDocParamsGet,
},
response: {
serialization: ApiKeyCreateSerialization,
},
})
);
}
export function ApiKeyUpdateDoc(): MethodDecorator {
return applyDecorators(
Doc<ResponseIdSerialization>('apiKey.update', {
auth: {
jwtAccessToken: true,
permissionToken: true,
},
request: {
params: ApiKeyDocParamsGet,
},
response: {
serialization: ResponseIdSerialization,
},
})
);
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsBoolean } from 'class-validator';
export class ApiKeyActiveDto {
@ApiProperty({
name: 'isActive',
required: true,
nullable: false,
})
@IsBoolean()
@IsNotEmpty()
@IsBoolean()
@IsNotEmpty()
isActive: boolean;
}

View File

@ -0,0 +1,57 @@
import { faker } from '@faker-js/faker';
import { ApiProperty, PartialType } from '@nestjs/swagger';
import {
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiKeyUpdateDateDto } from 'src/common/api-key/dtos/api-key.update-date.dto';
export class ApiKeyCreateDto extends PartialType(ApiKeyUpdateDateDto) {
@ApiProperty({
description: 'Api Key name',
example: `testapiname`,
required: true,
})
@IsNotEmpty()
@IsString()
@MaxLength(50)
name: string;
@ApiProperty({
description: 'Description of api key',
example: 'blabla description',
required: false,
})
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
description?: string;
}
export class ApiKeyCreateRawDto extends ApiKeyCreateDto {
@ApiProperty({
name: 'key',
example: faker.random.alphaNumeric(10),
required: true,
nullable: false,
})
@IsNotEmpty()
@IsString()
@MaxLength(50)
key: string;
@ApiProperty({
name: 'secret',
example: faker.random.alphaNumeric(20),
required: true,
nullable: false,
})
@IsNotEmpty()
@IsString()
@MaxLength(100)
secret: string;
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsUUID } from 'class-validator';
export class ApiKeyRequestDto {
@ApiProperty({
name: 'apiKey',
description: 'apiKey id',
required: true,
nullable: false,
})
@IsNotEmpty()
@IsUUID('4')
@Type(() => String)
apiKey: string;
}

View File

@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { faker } from '@faker-js/faker';
import { IsDate, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
import { MinGreaterThanEqual } from 'src/common/request/validations/request.min-greater-than-equal.validation';
import { MinDateToday } from 'src/common/request/validations/request.min-date-today.validation';
export class ApiKeyUpdateDateDto {
@ApiProperty({
description: 'Api Key start date',
example: faker.date.recent(),
required: false,
nullable: true,
})
@IsNotEmpty()
@Type(() => Date)
@IsDate()
@MinDateToday()
startDate: Date;
@ApiProperty({
description: 'Api Key end date',
example: faker.date.recent(),
required: false,
nullable: true,
})
@IsNotEmpty()
@Type(() => Date)
@IsDate()
@MinGreaterThanEqual('startDate')
endDate: Date;
}

View File

@ -0,0 +1,7 @@
import { PickType } from '@nestjs/swagger';
import { ApiKeyCreateDto } from './api-key.create.dto';
export class ApiKeyUpdateDto extends PickType(ApiKeyCreateDto, [
'name',
'description',
] as const) {}

View File

@ -0,0 +1,36 @@
import {
Injectable,
CanActivate,
ExecutionContext,
BadRequestException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { API_KEY_ACTIVE_META_KEY } from 'src/common/api-key/constants/api-key.constant';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
@Injectable()
export class ApiKeyActiveGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required: boolean[] = this.reflector.getAllAndOverride<boolean[]>(
API_KEY_ACTIVE_META_KEY,
[context.getHandler(), context.getClass()]
);
if (!required) {
return true;
}
const { __apiKey } = context.switchToHttp().getRequest();
if (!required.includes(__apiKey.isActive)) {
throw new BadRequestException({
statusCode:
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_IS_ACTIVE_ERROR,
message: 'apiKey.error.isActiveInvalid',
});
}
return true;
}
}

View File

@ -0,0 +1,31 @@
import {
Injectable,
CanActivate,
ExecutionContext,
BadRequestException,
} from '@nestjs/common';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
@Injectable()
export class ApiKeyExpiredGuard implements CanActivate {
constructor(private readonly helperDateService: HelperDateService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { __apiKey } = context.switchToHttp().getRequest();
const today: Date = this.helperDateService.create();
if (
__apiKey.startDate &&
__apiKey.endDate &&
today > __apiKey.endDate
) {
throw new BadRequestException({
statusCode:
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_EXPIRED_ERROR,
message: 'apiKey.error.expired',
});
}
return true;
}
}

View File

@ -0,0 +1,23 @@
import {
Injectable,
CanActivate,
ExecutionContext,
NotFoundException,
} from '@nestjs/common';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
@Injectable()
export class ApiKeyNotFoundGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const { __apiKey } = context.switchToHttp().getRequest();
if (!__apiKey) {
throw new NotFoundException({
statusCode:
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_NOT_FOUND_ERROR,
message: 'apiKey.error.notFound',
});
}
return true;
}
}

View File

@ -0,0 +1,19 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { ApiKeyDoc } from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
@Injectable()
export class ApiKeyPutToRequestGuard implements CanActivate {
constructor(private readonly apiKeyService: ApiKeyService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { params } = request;
const { apiKey } = params;
const check: ApiKeyDoc = await this.apiKeyService.findOneById(apiKey);
request.__apiKey = check;
return true;
}
}

View File

@ -0,0 +1,86 @@
import { AuthGuard } from '@nestjs/passport';
import {
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { HelperNumberService } from 'src/common/helper/services/helper.number.service';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
import { BadRequestError } from 'passport-headerapikey';
@Injectable()
export class ApiKeyXApiKeyGuard extends AuthGuard('api-key') {
constructor(private readonly helperNumberService: HelperNumberService) {
super();
}
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest<IApiKeyPayload = any>(
err: Record<string, any>,
apiKey: IApiKeyPayload,
info: Error | string
): IApiKeyPayload {
if (err || !apiKey) {
if (
info instanceof BadRequestError &&
info.message === 'Missing API Key'
) {
throw new UnauthorizedException({
statusCode:
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_NEEDED_ERROR,
message: 'apiKey.error.keyNeeded',
});
} else if (err) {
const statusCode: number = this.helperNumberService.create(
err.message as string
);
if (
statusCode ===
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_NOT_FOUND_ERROR
) {
throw new ForbiddenException({
statusCode,
message: 'apiKey.error.notFound',
});
} else if (
statusCode ===
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_IS_ACTIVE_ERROR
) {
throw new ForbiddenException({
statusCode,
message: 'apiKey.error.inactive',
});
} else if (
statusCode ===
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_EXPIRED_ERROR
) {
throw new ForbiddenException({
statusCode,
message: 'apiKey.error.expired',
});
} else if (
statusCode ===
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_WRONG_ERROR
) {
throw new ForbiddenException({
statusCode,
message: 'apiKey.error.wrong',
});
}
}
throw new UnauthorizedException({
statusCode:
ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_INVALID_ERROR,
message: 'apiKey.error.invalid',
});
}
return apiKey;
}
}

View File

@ -0,0 +1,119 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import Strategy from 'passport-headerapikey';
import { ENUM_API_KEY_STATUS_CODE_ERROR } from 'src/common/api-key/constants/api-key.status-code.constant';
import { ApiKeyEntity } from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
import { IRequestApp } from 'src/common/request/interfaces/request.interface';
@Injectable()
export class ApiKeyXApiKeyStrategy extends PassportStrategy(
Strategy,
'api-key'
) {
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly helperDateService: HelperDateService
) {
super(
{ header: 'X-API-KEY', prefix: '' },
true,
async (
apiKey: string,
verified: (
error: Error,
user?: Record<string, any>,
info?: string | number
) => Promise<void>,
req: IRequestApp
) => this.validate(apiKey, verified, req)
);
}
async validate(
apiKey: string,
verified: (
error: Error,
user?: ApiKeyEntity,
info?: string | number
) => Promise<void>,
req: IRequestApp
): Promise<void> {
const xApiKey: string[] = apiKey.split(':');
if (xApiKey.length !== 2) {
verified(
new Error(
`${ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_INVALID_ERROR}`
),
null,
null
);
return;
}
const key = xApiKey[0];
const secret = xApiKey[1];
const today = this.helperDateService.create();
const authApi: ApiKeyEntity =
await this.apiKeyService.findOneByActiveKey(key);
if (!authApi) {
verified(
new Error(
`${ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_NOT_FOUND_ERROR}`
),
null,
null
);
return;
} else if (!authApi.isActive) {
verified(
new Error(
`${ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_IS_ACTIVE_ERROR}`
),
null,
null
);
return;
} else if (
authApi.startDate &&
authApi.endDate &&
(authApi.startDate < today || authApi.endDate > today)
) {
verified(
new Error(
`${ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_EXPIRED_ERROR}`
),
null,
null
);
}
const validateApiKey: boolean =
await this.apiKeyService.validateHashApiKey(secret, authApi.hash);
if (!validateApiKey) {
verified(
new Error(
`${ENUM_API_KEY_STATUS_CODE_ERROR.API_KEY_INVALID_ERROR}`
),
null,
null
);
return;
}
req.apiKey = {
_id: `${authApi._id}`,
key: authApi.key,
name: authApi.name,
};
verified(null, authApi);
return;
}
}

View File

@ -0,0 +1,12 @@
import { ApiKeyDoc } from "src/common/api-key/repository/entities/api-key.entity";
export interface IApiKeyPayload {
_id: string;
key: string;
name: string;
}
export interface IApiKeyCreated {
secret: string;
doc: ApiKeyDoc;
}

View File

@ -0,0 +1,92 @@
import {
ApiKeyCreateDto,
ApiKeyCreateRawDto,
} from 'src/common/api-key/dtos/api-key.create.dto';
import { ApiKeyUpdateDateDto } from 'src/common/api-key/dtos/api-key.update-date.dto';
import { ApiKeyUpdateDto } from 'src/common/api-key/dtos/api-key.update.dto';
import { IApiKeyCreated } from 'src/common/api-key/interfaces/api-key.interface';
import {
ApiKeyDoc,
ApiKeyEntity,
} from 'src/common/api-key/repository/entities/api-key.entity';
import {
IDatabaseCreateOptions,
IDatabaseFindAllOptions,
IDatabaseFindOneOptions,
IDatabaseManyOptions,
IDatabaseOptions,
} from 'src/common/database/interfaces/database.interface';
export interface IApiKeyService {
findAll(
find?: Record<string, any>,
options?: IDatabaseFindAllOptions
): Promise<ApiKeyEntity[]>;
findOneById(
_id: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc>;
findOne(
find: Record<string, any>,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc>;
findOneByKey(
key: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc>;
findOneByActiveKey(
key: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc>;
getTotal(
find?: Record<string, any>,
options?: IDatabaseOptions
): Promise<number>;
create(
user: string,
{name, description, startDate, endDate}: ApiKeyCreateDto,
options?: IDatabaseCreateOptions
): Promise<IApiKeyCreated>;
createRaw(
user: string,
{name, description, key, secret, startDate, endDate}: ApiKeyCreateRawDto,
options?: IDatabaseCreateOptions
): Promise<IApiKeyCreated>;
active(repository: ApiKeyDoc): Promise<ApiKeyDoc>;
inactive(repository: ApiKeyDoc): Promise<ApiKeyDoc>;
update(repository: ApiKeyDoc, data: ApiKeyUpdateDto): Promise<ApiKeyDoc>;
updateDate(
repository: ApiKeyDoc,
{startDate, endDate}: ApiKeyUpdateDateDto
): Promise<ApiKeyDoc>;
reset(repository: ApiKeyDoc, secret: string): Promise<ApiKeyDoc>;
delete(repository: ApiKeyDoc): Promise<ApiKeyDoc>;
validateHashApiKey(hashFromRequest: string, hash: string): Promise<boolean>;
createKey(): Promise<string>;
createSecret(): Promise<string>;
createHashApiKey(key: string, secret: string): Promise<string>;
deleteMany(
find: Record<string, any>,
options?: IDatabaseManyOptions
): Promise<boolean>;
inactiveManyByEndDate(options?: IDatabaseManyOptions): Promise<boolean>;
}

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import {
ApiKeyEntity,
ApiKeySchema,
} from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyRepository } from 'src/common/api-key/repository/repositories/api-key.repository';
import { DATABASE_CONNECTION_NAME } from 'src/common/database/constants/database.constant';
@Module({
providers: [ApiKeyRepository],
exports: [ApiKeyRepository],
controllers: [],
imports: [
MongooseModule.forFeature(
[
{
name: ApiKeyEntity.name,
schema: ApiKeySchema,
},
],
DATABASE_CONNECTION_NAME
),
],
})
export class ApiKeyRepositoryModule {}

View File

@ -0,0 +1,85 @@
import { Prop, SchemaFactory } from '@nestjs/mongoose';
import { CallbackWithoutResultAndOptionalError } from 'mongoose';
import { DatabaseMongoUUIDEntityAbstract } from 'src/common/database/abstracts/mongo/entities/database.mongo.uuid.entity.abstract';
import { DatabaseEntity } from 'src/common/database/decorators/database.decorator';
import { Document } from 'mongoose';
import { UserEntity } from "src/modules/user/repository/entities/user.entity";
export const ApiKeyDatabaseName = 'apikeys';
@DatabaseEntity({ collection: ApiKeyDatabaseName })
export class ApiKeyEntity extends DatabaseMongoUUIDEntityAbstract {
@Prop({
required: true,
ref: UserEntity.name,
index: true,
})
user: string;
@Prop({
required: true,
index: true,
type: String,
minlength: 1,
maxlength: 100,
lowercase: true,
trim: true,
})
name: string;
@Prop({
required: false,
type: String,
minlength: 1,
maxlength: 255,
})
description?: string;
@Prop({
required: true,
type: String,
unique: true,
index: true,
trim: true,
})
key: string;
@Prop({
required: true,
trim: true,
type: String,
})
hash: string;
@Prop({
required: true,
index: true,
type: Boolean,
})
isActive: boolean;
@Prop({
required: false,
type: Date,
})
startDate?: Date;
@Prop({
required: false,
type: Date,
})
endDate?: Date;
}
export const ApiKeySchema = SchemaFactory.createForClass(ApiKeyEntity);
export type ApiKeyDoc = ApiKeyEntity & Document<string>;
ApiKeySchema.pre(
'save',
function (next: CallbackWithoutResultAndOptionalError) {
this.name = this.name.toLowerCase();
next();
}
);

View File

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import {
ApiKeyEntity,
ApiKeyDoc,
} from 'src/common/api-key/repository/entities/api-key.entity';
import { DatabaseMongoUUIDRepositoryAbstract } from 'src/common/database/abstracts/mongo/repositories/database.mongo.uuid.repository.abstract';
import { DatabaseModel } from 'src/common/database/decorators/database.decorator';
@Injectable()
export class ApiKeyRepository extends DatabaseMongoUUIDRepositoryAbstract<
ApiKeyEntity,
ApiKeyDoc
> {
constructor(
@DatabaseModel(ApiKeyEntity.name)
private readonly ApiKeyDoc: Model<ApiKeyEntity>
) {
super(ApiKeyDoc);
}
}

View File

@ -0,0 +1,14 @@
import { ApiProperty, PickType } from '@nestjs/swagger';
import { ApiKeyGetSerialization } from 'src/common/api-key/serializations/api-key.get.serialization';
export class ApiKeyCreateSerialization extends PickType(
ApiKeyGetSerialization,
['key', '_id'] as const
) {
@ApiProperty({
description: 'Secret key of ApiKey, only show at once',
example: true,
required: true,
})
secret: string;
}

View File

@ -0,0 +1,70 @@
import { faker } from '@faker-js/faker';
import { ApiProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
import { ResponseIdSerialization } from 'src/common/response/serializations/response.id.serialization';
export class ApiKeyGetSerialization extends ResponseIdSerialization {
@ApiProperty({
description: 'Alias name of api key',
example: faker.name.jobTitle(),
required: true,
})
name: string;
@ApiProperty({
description: 'Description of api key',
example: 'blabla description',
required: false,
})
description?: string;
@ApiProperty({
description: 'Unique key of api key',
example: faker.random.alpha(115),
required: true,
})
key: string;
@Exclude()
hash: string;
@ApiProperty({
description: 'Active flag of api key',
example: true,
required: true,
})
isActive: boolean;
@ApiProperty({
description: 'Api Key start date',
example: faker.date.recent(),
required: false,
nullable: true,
})
startDate?: Date;
@ApiProperty({
description: 'Api Key end date',
example: faker.date.recent(),
required: false,
nullable: true,
})
endDate?: Date;
@ApiProperty({
description: 'Date created at',
example: faker.date.recent(),
required: false,
})
readonly createdAt: Date;
@ApiProperty({
description: 'Date updated at',
example: faker.date.recent(),
required: false,
})
readonly updatedAt: Date;
@Exclude()
readonly deletedAt?: Date;
}

View File

@ -0,0 +1,6 @@
import { OmitType } from '@nestjs/swagger';
import { ApiKeyGetSerialization } from 'src/common/api-key/serializations/api-key.get.serialization';
export class ApiKeyListSerialization extends OmitType(ApiKeyGetSerialization, [
'description',
] as const) {}

View File

@ -0,0 +1,3 @@
import { ApiKeyCreateSerialization } from 'src/common/api-key/serializations/api-key.create.serialization';
export class ApiKeyResetSerialization extends ApiKeyCreateSerialization {}

View File

@ -0,0 +1,260 @@
import { Injectable } from '@nestjs/common';
import {
IDatabaseCreateOptions,
IDatabaseFindAllOptions,
IDatabaseFindOneOptions,
IDatabaseOptions,
IDatabaseManyOptions,
IDatabaseExistOptions,
} from 'src/common/database/interfaces/database.interface';
import { IApiKeyService } from 'src/common/api-key/interfaces/api-key.service.interface';
import { IApiKeyCreated } from 'src/common/api-key/interfaces/api-key.interface';
import {
ApiKeyDoc,
ApiKeyEntity,
} from 'src/common/api-key/repository/entities/api-key.entity';
import { ApiKeyRepository } from 'src/common/api-key/repository/repositories/api-key.repository';
import { ApiKeyActiveDto } from 'src/common/api-key/dtos/api-key.active.dto';
import { HelperStringService } from 'src/common/helper/services/helper.string.service';
import { ConfigService } from '@nestjs/config';
import { HelperHashService } from 'src/common/helper/services/helper.hash.service';
import {
ApiKeyCreateDto,
ApiKeyCreateRawDto,
} from 'src/common/api-key/dtos/api-key.create.dto';
import { ApiKeyUpdateDto } from 'src/common/api-key/dtos/api-key.update.dto';
import { ApiKeyUpdateDateDto } from 'src/common/api-key/dtos/api-key.update-date.dto';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
@Injectable()
export class ApiKeyService implements IApiKeyService {
private readonly env: string;
constructor(
private readonly helperStringService: HelperStringService,
private readonly configService: ConfigService,
private readonly helperHashService: HelperHashService,
private readonly helperDateService: HelperDateService,
private readonly apiKeyRepository: ApiKeyRepository
) {
this.env = this.configService.get<string>('app.env');
}
async findAll(
find?: Record<string, any>,
options?: IDatabaseFindAllOptions
): Promise<ApiKeyEntity[]> {
return this.apiKeyRepository.findAll<ApiKeyEntity>(find, options);
}
async findOneById(
_id: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc> {
return this.apiKeyRepository.findOneById<ApiKeyDoc>(_id, options);
}
async findOne(
find: Record<string, any>,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc> {
return this.apiKeyRepository.findOne<ApiKeyDoc>(find, options);
}
async existByUser(
user: string,
options?: IDatabaseExistOptions
): Promise<boolean> {
return this.apiKeyRepository.exists(
{
user,
},
options
);
}
async findOneByKey(
key: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc> {
return this.apiKeyRepository.findOne<ApiKeyDoc>({ key }, options);
}
async findOneByActiveKey(
key: string,
options?: IDatabaseFindOneOptions
): Promise<ApiKeyDoc> {
return this.apiKeyRepository.findOne<ApiKeyDoc>(
{
key,
isActive: true,
},
options
);
}
async getTotal(
find?: Record<string, any>,
options?: IDatabaseOptions
): Promise<number> {
return this.apiKeyRepository.getTotal(find, options);
}
async create(
user: string,
{ name, description, startDate, endDate }: ApiKeyCreateDto,
options?: IDatabaseCreateOptions
): Promise<IApiKeyCreated> {
const key = await this.createKey();
const secret = await this.createSecret();
const hash: string = await this.createHashApiKey(key, secret);
const dto: ApiKeyEntity = new ApiKeyEntity();
dto.user = user;
dto.name = name;
dto.description = description;
dto.key = key;
dto.hash = hash;
dto.isActive = true;
if (startDate && endDate) {
dto.startDate = startDate;
dto.endDate = endDate;
}
const created: ApiKeyDoc =
await this.apiKeyRepository.create<ApiKeyEntity>(dto, options);
return { doc: created, secret };
}
async createRaw(
user: string,
{
name,
description,
key,
secret,
startDate,
endDate,
}: ApiKeyCreateRawDto,
options?: IDatabaseCreateOptions
): Promise<IApiKeyCreated> {
const hash: string = await this.createHashApiKey(key, secret);
const dto: ApiKeyEntity = new ApiKeyEntity();
dto.name = name;
dto.user = user;
dto.description = description;
dto.key = key;
dto.hash = hash;
dto.isActive = true;
if (startDate && endDate) {
dto.startDate = this.helperDateService.startOfDay(startDate);
dto.endDate = this.helperDateService.endOfDay(endDate);
}
const created: ApiKeyDoc =
await this.apiKeyRepository.create<ApiKeyEntity>(dto, options);
return { doc: created, secret };
}
async active(repository: ApiKeyDoc): Promise<ApiKeyDoc> {
repository.isActive = true;
return this.apiKeyRepository.save(repository);
}
async inactive(repository: ApiKeyDoc): Promise<ApiKeyDoc> {
repository.isActive = false;
return this.apiKeyRepository.save(repository);
}
async update(
repository: ApiKeyDoc,
{ name, description }: ApiKeyUpdateDto
): Promise<ApiKeyDoc> {
repository.name = name;
repository.description = description;
return this.apiKeyRepository.save(repository);
}
async updateDate(
repository: ApiKeyDoc,
{ startDate, endDate }: ApiKeyUpdateDateDto
): Promise<ApiKeyDoc> {
repository.startDate = this.helperDateService.startOfDay(startDate);
repository.endDate = this.helperDateService.endOfDay(endDate);
return this.apiKeyRepository.save(repository);
}
async reset(repository: ApiKeyDoc, secret: string): Promise<ApiKeyDoc> {
const hash: string = await this.createHashApiKey(
repository.key,
secret
);
repository.hash = hash;
return this.apiKeyRepository.save(repository);
}
async delete(repository: ApiKeyDoc): Promise<ApiKeyDoc> {
return this.apiKeyRepository.softDelete(repository);
}
async validateHashApiKey(
hashFromRequest: string,
hash: string
): Promise<boolean> {
return this.helperHashService.sha256Compare(hashFromRequest, hash);
}
async createKey(): Promise<string> {
return this.helperStringService.random(25, {
safe: false,
upperCase: true,
prefix: `${this.env}_`,
});
}
async createSecret(): Promise<string> {
return this.helperStringService.random(35, {
safe: false,
upperCase: true,
});
}
async createHashApiKey(key: string, secret: string): Promise<string> {
return this.helperHashService.sha256(`${key}:${secret}`);
}
async deleteMany(
find: Record<string, any>,
options?: IDatabaseManyOptions
): Promise<boolean> {
return this.apiKeyRepository.deleteMany(find, options);
}
async inactiveManyByEndDate(
options?: IDatabaseManyOptions
): Promise<boolean> {
return this.apiKeyRepository.updateMany<ApiKeyActiveDto>(
{
endDate: {
$lte: this.helperDateService.create(),
},
isActive: true,
},
{
isActive: false,
},
options
);
}
}

View File

@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ApiKeyService } from 'src/common/api-key/services/api-key.service';
@Injectable()
export class ApiKeyInactiveTask {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, {
name: 'inactiveApiKey',
})
async inactiveApiKey(): Promise<void> {
try {
await this.apiKeyService.inactiveManyByEndDate();
} catch (err: any) {
throw new Error(err.message);
}
return;
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthJwtAccessStrategy } from 'src/common/auth/guards/jwt-access/auth.jwt-access.strategy';
import { AuthJwtRefreshStrategy } from 'src/common/auth/guards/jwt-refresh/auth.jwt-refresh.strategy';
import { AuthService } from 'src/common/auth/services/auth.service';
@Module({
providers: [AuthService, AuthJwtAccessStrategy, AuthJwtRefreshStrategy],
exports: [AuthService],
controllers: [],
imports: [],
})
export class AuthModule {}

View File

@ -0,0 +1,2 @@
export const AUTH_ACCESS_FOR_META_KEY = 'AuthAccessForMetaKey';
export const AUTH_PERMISSION_META_KEY = 'AuthPermissionMetaKey';

View File

@ -0,0 +1,17 @@
export enum ENUM_AUTH_ACCESS_FOR_SUPER_ADMIN {
SUPER_ADMIN = 'SUPER_ADMIN',
}
export enum ENUM_AUTH_ACCESS_FOR_DEFAULT {
USER = 'USER',
ADMIN = 'ADMIN',
}
export const ENUM_AUTH_ACCESS_FOR = {
...ENUM_AUTH_ACCESS_FOR_SUPER_ADMIN,
...ENUM_AUTH_ACCESS_FOR_DEFAULT,
};
export type ENUM_AUTH_ACCESS_FOR =
| ENUM_AUTH_ACCESS_FOR_SUPER_ADMIN
| ENUM_AUTH_ACCESS_FOR_DEFAULT;

View File

@ -0,0 +1,32 @@
export enum ENUM_AUTH_PERMISSIONS {
API_KEY_READ = 'API_KEY_READ',
API_KEY_UPDATE = 'API_KEY_UPDATE',
API_KEY_ACTIVE = 'API_KEY_ACTIVE',
API_KEY_INACTIVE = 'API_KEY_INACTIVE',
API_KEY_UPDATE_DATE = 'API_KEY_UPDATE_DATE',
USER_READ = 'USER_READ',
USER_CREATE = 'USER_CREATE',
USER_UPDATE = 'USER_UPDATE',
USER_ACTIVE = 'USER_ACTIVE',
USER_INACTIVE = 'USER_INACTIVE',
USER_BLOCKED = 'USER_BLOCKED',
USER_DELETE = 'USER_DELETE',
USER_IMPORT = 'USER_IMPORT',
USER_EXPORT = 'USER_EXPORT',
ROLE_CREATE = 'ROLE_CREATE',
ROLE_UPDATE = 'ROLE_UPDATE',
ROLE_ACTIVE = 'ROLE_ACTIVE',
ROLE_INACTIVE = 'ROLE_INACTIVE',
ROLE_READ = 'ROLE_READ',
ROLE_DELETE = 'ROLE_DELETE',
PERMISSION_READ = 'PERMISSION_READ',
PERMISSION_UPDATE = 'PERMISSION_UPDATE',
PERMISSION_ACTIVE = 'PERMISSION_ACTIVE',
PERMISSION_INACTIVE = 'PERMISSION_INACTIVE',
SETTING_READ = 'SETTING_READ',
SETTING_UPDATE = 'SETTING_UPDATE',
}

View File

@ -0,0 +1,10 @@
export enum ENUM_AUTH_STATUS_CODE_ERROR {
AUTH_JWT_ACCESS_TOKEN_ERROR = 5100,
AUTH_JWT_REFRESH_TOKEN_ERROR = 5101,
AUTH_PERMISSION_TOKEN_ERROR = 5102,
AUTH_PERMISSION_TOKEN_INVALID_ERROR = 5103,
AUTH_PERMISSION_TOKEN_NOT_YOUR_ERROR = 5104,
AUTH_ROLE_ACCESS_FOR_INVALID_ERROR = 5105,
AUTH_PERMISSION_INVALID_ERROR = 5106,
AUTH_ACCESS_FOR_INVALID_ERROR = 5107,
}

View File

@ -0,0 +1,49 @@
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { AUTH_ACCESS_FOR_META_KEY } from 'src/common/auth/constants/auth.constant';
import { ENUM_AUTH_ACCESS_FOR } from 'src/common/auth/constants/auth.enum.constant';
import { AuthJwtAccessGuard } from 'src/common/auth/guards/jwt-access/auth.jwt-access.guard';
import { AuthJwtRefreshGuard } from 'src/common/auth/guards/jwt-refresh/auth.jwt-refresh.guard';
import { AuthPayloadAccessForGuard } from 'src/common/auth/guards/payload/auth.payload.access-for.guard';
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const AuthJwtPayload = createParamDecorator(
(data: string, ctx: ExecutionContext): Record<string, any> => {
const { user } = ctx.switchToHttp().getRequest();
return data ? user[data] : user;
}
);
export const AuthJwtToken = createParamDecorator(
(data: string, ctx: ExecutionContext): string => {
const { headers } = ctx.switchToHttp().getRequest();
const { authorization } = headers;
const authorizations: string[] = authorization.split(' ');
return authorizations.length >= 2 ? authorizations[1] : undefined;
}
);
export function AuthJwtAccessProtected(): MethodDecorator {
return applyDecorators(UseGuards(AuthJwtAccessGuard));
}
export function AuthJwtPublicAccessProtected(): MethodDecorator {
return applyDecorators(
UseGuards(AuthJwtAccessGuard, AuthPayloadAccessForGuard),
SetMetadata(AUTH_ACCESS_FOR_META_KEY, [ENUM_AUTH_ACCESS_FOR.USER])
);
}
export function AuthJwtAdminAccessProtected(): MethodDecorator {
return applyDecorators(
UseGuards(AuthJwtAccessGuard, AuthPayloadAccessForGuard),
SetMetadata(AUTH_ACCESS_FOR_META_KEY, [
ENUM_AUTH_ACCESS_FOR.SUPER_ADMIN,
ENUM_AUTH_ACCESS_FOR.ADMIN,
])
);
}
export function AuthJwtRefreshProtected(): MethodDecorator {
return applyDecorators(UseGuards(AuthJwtRefreshGuard));
}

View File

@ -0,0 +1,27 @@
import {
applyDecorators,
createParamDecorator,
ExecutionContext,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { AUTH_PERMISSION_META_KEY } from 'src/common/auth/constants/auth.constant';
import { ENUM_AUTH_PERMISSIONS } from 'src/common/auth/constants/auth.enum.permission.constant';
import { AuthPayloadPermissionGuard } from 'src/common/auth/guards/payload/auth.payload.permission.guard';
import { AuthPermissionGuard } from 'src/common/auth/guards/permission/auth.permission.guard';
export const AuthPermissionPayload = createParamDecorator(
(data: string, ctx: ExecutionContext): Record<string, any> => {
const { permissions } = ctx.switchToHttp().getRequest();
return permissions;
}
);
export function AuthPermissionProtected(
...permissions: ENUM_AUTH_PERMISSIONS[]
): MethodDecorator {
return applyDecorators(
UseGuards(AuthPermissionGuard, AuthPayloadPermissionGuard),
SetMetadata(AUTH_PERMISSION_META_KEY, permissions)
);
}

View File

@ -0,0 +1,19 @@
import { AuthGuard } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ENUM_AUTH_STATUS_CODE_ERROR } from 'src/common/auth/constants/auth.status-code.constant';
@Injectable()
export class AuthJwtAccessGuard extends AuthGuard('jwt') {
handleRequest<TUser = any>(err: Error, user: TUser, info: Error): TUser {
if (err || !user) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_JWT_ACCESS_TOKEN_ERROR,
message: 'auth.error.accessTokenUnauthorized',
_error: err ? err.message : info.message,
});
}
return user;
}
}

View File

@ -0,0 +1,40 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from 'src/common/auth/services/auth.service';
@Injectable()
export class AuthJwtAccessStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme(
configService.get<string>('auth.prefixAuthorization')
),
ignoreExpiration: false,
jsonWebTokenOptions: {
ignoreNotBefore: false,
audience: configService.get<string>('auth.audience'),
issuer: configService.get<string>('auth.issuer'),
subject: configService.get<string>('auth.subject'),
},
secretOrKey: configService.get<string>(
'auth.accessToken.secretKey'
),
});
}
async validate({
data,
}: Record<string, any>): Promise<Record<string, any>> {
const payloadEncryption: boolean =
await this.authService.getPayloadEncryption();
return payloadEncryption
? this.authService.decryptAccessToken({ data })
: data;
}
}

View File

@ -0,0 +1,19 @@
import { AuthGuard } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ENUM_AUTH_STATUS_CODE_ERROR } from 'src/common/auth/constants/auth.status-code.constant';
@Injectable()
export class AuthJwtRefreshGuard extends AuthGuard('jwtRefresh') {
handleRequest<TUser = any>(err: Error, user: TUser, info: Error): TUser {
if (err || !user) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_JWT_REFRESH_TOKEN_ERROR,
message: 'auth.error.refreshTokenUnauthorized',
_error: err ? err.message : info.message,
});
}
return user;
}
}

View File

@ -0,0 +1,43 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from 'src/common/auth/services/auth.service';
@Injectable()
export class AuthJwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwtRefresh'
) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme(
configService.get<string>('auth.prefixAuthorization')
),
ignoreExpiration: false,
jsonWebTokenOptions: {
ignoreNotBefore: false,
audience: configService.get<string>('auth.audience'),
issuer: configService.get<string>('auth.issuer'),
subject: configService.get<string>('auth.subject'),
},
secretOrKey: configService.get<string>(
'auth.refreshToken.secretKey'
),
});
}
async validate({
data,
}: Record<string, any>): Promise<Record<string, any>> {
const payloadEncryption: boolean =
await this.authService.getPayloadEncryption();
return payloadEncryption
? this.authService.decryptRefreshToken({ data })
: data;
}
}

View File

@ -0,0 +1,48 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AUTH_ACCESS_FOR_META_KEY } from 'src/common/auth/constants/auth.constant';
import { ENUM_AUTH_ACCESS_FOR } from 'src/common/auth/constants/auth.enum.constant';
import { ENUM_AUTH_STATUS_CODE_ERROR } from 'src/common/auth/constants/auth.status-code.constant';
import { HelperArrayService } from 'src/common/helper/services/helper.array.service';
@Injectable()
export class AuthPayloadAccessForGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly helperArrayService: HelperArrayService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredFor: ENUM_AUTH_ACCESS_FOR[] =
this.reflector.getAllAndOverride<ENUM_AUTH_ACCESS_FOR[]>(
AUTH_ACCESS_FOR_META_KEY,
[context.getHandler(), context.getClass()]
);
const { user } = context.switchToHttp().getRequest();
const { accessFor } = user;
if (!requiredFor || accessFor === ENUM_AUTH_ACCESS_FOR.SUPER_ADMIN) {
return true;
}
const hasFor: boolean = this.helperArrayService.includes(
requiredFor,
accessFor
);
if (!hasFor) {
throw new ForbiddenException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_ACCESS_FOR_INVALID_ERROR,
message: 'auth.error.accessForForbidden',
});
}
return hasFor;
}
}

View File

@ -0,0 +1,57 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AUTH_PERMISSION_META_KEY } from 'src/common/auth/constants/auth.constant';
import { ENUM_AUTH_ACCESS_FOR } from 'src/common/auth/constants/auth.enum.constant';
import { ENUM_AUTH_PERMISSIONS } from 'src/common/auth/constants/auth.enum.permission.constant';
import { ENUM_AUTH_STATUS_CODE_ERROR } from 'src/common/auth/constants/auth.status-code.constant';
import { HelperArrayService } from 'src/common/helper/services/helper.array.service';
@Injectable()
export class AuthPayloadPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly helperArrayService: HelperArrayService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermission: ENUM_AUTH_PERMISSIONS[] =
this.reflector.getAllAndOverride<ENUM_AUTH_PERMISSIONS[]>(
AUTH_PERMISSION_META_KEY,
[context.getHandler(), context.getClass()]
);
const { permissions, user } = context.switchToHttp().getRequest();
if (!user) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_JWT_ACCESS_TOKEN_ERROR,
message: 'auth.error.accessTokenUnauthorized',
});
} else if (
!requiredPermission ||
user.accessFor === ENUM_AUTH_ACCESS_FOR.SUPER_ADMIN
) {
return true;
}
const hasPermission: boolean = this.helperArrayService.in(
permissions,
requiredPermission
);
if (!hasPermission) {
throw new ForbiddenException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_PERMISSION_INVALID_ERROR,
message: 'auth.error.permissionForbidden',
});
}
return hasPermission;
}
}

View File

@ -0,0 +1,81 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ENUM_AUTH_ACCESS_FOR } from 'src/common/auth/constants/auth.enum.constant';
import { ENUM_AUTH_STATUS_CODE_ERROR } from 'src/common/auth/constants/auth.status-code.constant';
import { AuthService } from 'src/common/auth/services/auth.service';
@Injectable()
export class AuthPermissionGuard implements CanActivate {
private readonly headerName: string;
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService
) {
this.headerName = this.configService.get<string>(
'auth.permissionToken.headerName'
);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { user } = request;
if (!user) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_JWT_ACCESS_TOKEN_ERROR,
message: 'auth.error.accessTokenUnauthorized',
});
} else if (user.accessFor === ENUM_AUTH_ACCESS_FOR.SUPER_ADMIN) {
return true;
}
const { [this.headerName]: token } = request.headers;
if (!token) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_PERMISSION_TOKEN_ERROR,
message: 'auth.error.permissionTokenUnauthorized',
});
}
const validate: boolean =
await this.authService.validatePermissionToken(token);
if (!validate) {
throw new UnauthorizedException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_PERMISSION_TOKEN_INVALID_ERROR,
message: 'auth.error.permissionTokenInvalid',
});
}
const { data } = await this.authService.payloadPermissionToken(token);
const payloadEncryption = await this.authService.getPayloadEncryption();
let payloadDecryptPermissionToken: Record<string, any> = data;
if (payloadEncryption) {
payloadDecryptPermissionToken =
await this.authService.decryptPermissionToken(data);
}
if (payloadDecryptPermissionToken._id !== user._id) {
throw new ForbiddenException({
statusCode:
ENUM_AUTH_STATUS_CODE_ERROR.AUTH_PERMISSION_TOKEN_NOT_YOUR_ERROR,
message: 'auth.error.permissionTokenNotYour',
});
}
request.permissions = payloadDecryptPermissionToken.permissions;
return true;
}
}

View File

@ -0,0 +1,17 @@
// Auth
export interface IAuthPassword {
salt: string;
passwordHash: string;
passwordExpired: Date;
passwordCreated: Date;
}
export interface IAuthPayloadOptions {
loginDate: Date;
}
export interface IAuthRefreshTokenOptions {
// in milis
notBeforeExpirationTime?: number | string;
rememberMe?: boolean;
}

View File

@ -0,0 +1,93 @@
import {
IAuthPassword,
IAuthPayloadOptions,
IAuthRefreshTokenOptions,
} from 'src/common/auth/interfaces/auth.interface';
export interface IAuthService {
encryptAccessToken(payload: Record<string, any>): Promise<string>;
decryptAccessToken(
payload: Record<string, any>
): Promise<Record<string, any>>;
createAccessToken(
payloadHashed: string | Record<string, any>
): Promise<string>;
validateAccessToken(token: string): Promise<boolean>;
payloadAccessToken(token: string): Promise<Record<string, any>>;
encryptRefreshToken(payload: Record<string, any>): Promise<string>;
decryptRefreshToken(
payload: Record<string, any>
): Promise<Record<string, any>>;
createRefreshToken(
payloadHashed: string | Record<string, any>,
options?: IAuthRefreshTokenOptions
): Promise<string>;
validateRefreshToken(token: string): Promise<boolean>;
payloadRefreshToken(token: string): Promise<Record<string, any>>;
encryptPermissionToken(payload: Record<string, any>): Promise<string>;
decryptPermissionToken({
data,
}: Record<string, any>): Promise<Record<string, any>>;
createPermissionToken(
payloadHashed: string | Record<string, any>
): Promise<string>;
validatePermissionToken(token: string): Promise<boolean>;
payloadPermissionToken(token: string): Promise<Record<string, any>>;
validateUser(
passwordString: string,
passwordHash: string
): Promise<boolean>;
createPayloadAccessToken(
data: Record<string, any>,
rememberMe: boolean,
options?: IAuthPayloadOptions
): Promise<Record<string, any>>;
createPayloadRefreshToken(
_id: string,
rememberMe: boolean,
options?: IAuthPayloadOptions
): Promise<Record<string, any>>;
createPayloadPermissionToken(
data: Record<string, any>
): Promise<Record<string, any>>;
createSalt(length: number): Promise<string>;
createPassword(password: string): Promise<IAuthPassword>;
checkPasswordExpired(passwordExpired: Date): Promise<boolean>;
getTokenType(): Promise<string>;
getAccessTokenExpirationTime(): Promise<number>;
getRefreshTokenExpirationTime(rememberMe?: boolean): Promise<number>;
getIssuer(): Promise<string>;
getAudience(): Promise<string>;
getSubject(): Promise<string>;
getPayloadEncryption(): Promise<boolean>;
getPermissionTokenExpirationTime(): Promise<number>;
}

View File

@ -0,0 +1,371 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
IAuthPassword,
IAuthPayloadOptions,
IAuthRefreshTokenOptions,
} from 'src/common/auth/interfaces/auth.interface';
import { IAuthService } from 'src/common/auth/interfaces/auth.service.interface';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
import { HelperEncryptionService } from 'src/common/helper/services/helper.encryption.service';
import { HelperHashService } from 'src/common/helper/services/helper.hash.service';
@Injectable()
export class AuthService implements IAuthService {
private readonly accessTokenSecretKey: string;
private readonly accessTokenExpirationTime: number;
private readonly accessTokenNotBeforeExpirationTime: number;
private readonly accessTokenEncryptKey: string;
private readonly accessTokenEncryptIv: string;
private readonly refreshTokenSecretKey: string;
private readonly refreshTokenExpirationTime: number;
private readonly refreshTokenExpirationTimeRememberMe: number;
private readonly refreshTokenNotBeforeExpirationTime: number;
private readonly refreshTokenEncryptKey: string;
private readonly refreshTokenEncryptIv: string;
private readonly payloadEncryption: boolean;
private readonly prefixAuthorization: string;
private readonly audience: string;
private readonly issuer: string;
private readonly subject: string;
private readonly passwordExpiredIn: number;
private readonly passwordSaltLength: number;
private readonly permissionTokenSecretToken: string;
private readonly permissionTokenExpirationTime: number;
private readonly permissionTokenNotBeforeExpirationTime: number;
private readonly permissionTokenEncryptKey: string;
private readonly permissionTokenEncryptIv: string;
constructor(
private readonly helperHashService: HelperHashService,
private readonly helperDateService: HelperDateService,
private readonly helperEncryptionService: HelperEncryptionService,
private readonly configService: ConfigService
) {
this.accessTokenSecretKey = this.configService.get<string>(
'auth.accessToken.secretKey'
);
this.accessTokenExpirationTime = this.configService.get<number>(
'auth.accessToken.expirationTime'
);
this.accessTokenNotBeforeExpirationTime =
this.configService.get<number>(
'auth.accessToken.notBeforeExpirationTime'
);
this.accessTokenEncryptKey = this.configService.get<string>(
'auth.accessToken.encryptKey'
);
this.accessTokenEncryptIv = this.configService.get<string>(
'auth.accessToken.encryptIv'
);
this.refreshTokenSecretKey = this.configService.get<string>(
'auth.refreshToken.secretKey'
);
this.refreshTokenExpirationTime = this.configService.get<number>(
'auth.refreshToken.expirationTime'
);
this.refreshTokenExpirationTimeRememberMe =
this.configService.get<number>(
'auth.refreshToken.expirationTimeRememberMe'
);
this.refreshTokenNotBeforeExpirationTime =
this.configService.get<number>(
'auth.refreshToken.notBeforeExpirationTime'
);
this.refreshTokenEncryptKey = this.configService.get<string>(
'auth.refreshToken.encryptKey'
);
this.refreshTokenEncryptIv = this.configService.get<string>(
'auth.refreshToken.encryptIv'
);
this.payloadEncryption = this.configService.get<boolean>(
'auth.payloadEncryption'
);
this.prefixAuthorization = this.configService.get<string>(
'auth.prefixAuthorization'
);
this.subject = this.configService.get<string>('auth.subject');
this.audience = this.configService.get<string>('auth.audience');
this.issuer = this.configService.get<string>('auth.issuer');
this.passwordExpiredIn = this.configService.get<number>(
'auth.password.expiredIn'
);
this.passwordSaltLength = this.configService.get<number>(
'auth.password.saltLength'
);
this.permissionTokenSecretToken = this.configService.get<string>(
'auth.permissionToken.secretKey'
);
this.permissionTokenExpirationTime = this.configService.get<number>(
'auth.permissionToken.expirationTime'
);
this.permissionTokenNotBeforeExpirationTime =
this.configService.get<number>(
'auth.permissionToken.notBeforeExpirationTime'
);
this.permissionTokenEncryptKey = this.configService.get<string>(
'auth.permissionToken.encryptKey'
);
this.permissionTokenEncryptIv = this.configService.get<string>(
'auth.permissionToken.encryptIv'
);
}
async createSalt(length: number): Promise<string> {
return this.helperHashService.randomSalt(length);
}
async encryptAccessToken(payload: Record<string, any>): Promise<string> {
return this.helperEncryptionService.aes256Encrypt(
payload,
this.accessTokenEncryptKey,
this.accessTokenEncryptIv
);
}
async decryptAccessToken({
data,
}: Record<string, any>): Promise<Record<string, any>> {
return this.helperEncryptionService.aes256Decrypt(
data,
this.accessTokenEncryptKey,
this.accessTokenEncryptIv
) as Record<string, any>;
}
async createAccessToken(
payloadHashed: string | Record<string, any>
): Promise<string> {
return this.helperEncryptionService.jwtEncrypt(
{ data: payloadHashed },
{
secretKey: this.accessTokenSecretKey,
expiredIn: this.accessTokenExpirationTime,
notBefore: this.accessTokenNotBeforeExpirationTime,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
}
);
}
async validateAccessToken(token: string): Promise<boolean> {
return this.helperEncryptionService.jwtVerify(token, {
secretKey: this.accessTokenSecretKey,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
});
}
async payloadAccessToken(token: string): Promise<Record<string, any>> {
return this.helperEncryptionService.jwtDecrypt(token);
}
async encryptRefreshToken(payload: Record<string, any>): Promise<string> {
return this.helperEncryptionService.aes256Encrypt(
payload,
this.refreshTokenEncryptKey,
this.refreshTokenEncryptIv
);
}
async decryptRefreshToken({
data,
}: Record<string, any>): Promise<Record<string, any>> {
return this.helperEncryptionService.aes256Decrypt(
data,
this.refreshTokenEncryptKey,
this.refreshTokenEncryptIv
) as Record<string, any>;
}
async createRefreshToken(
payloadHashed: string | Record<string, any>,
options?: IAuthRefreshTokenOptions
): Promise<string> {
return this.helperEncryptionService.jwtEncrypt(
{ data: payloadHashed },
{
secretKey: this.refreshTokenSecretKey,
expiredIn: options?.rememberMe
? this.refreshTokenExpirationTimeRememberMe
: this.refreshTokenExpirationTime,
notBefore:
options?.notBeforeExpirationTime ??
this.refreshTokenNotBeforeExpirationTime,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
}
);
}
async validateRefreshToken(token: string): Promise<boolean> {
return this.helperEncryptionService.jwtVerify(token, {
secretKey: this.refreshTokenSecretKey,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
});
}
async payloadRefreshToken(token: string): Promise<Record<string, any>> {
return this.helperEncryptionService.jwtDecrypt(token);
}
async encryptPermissionToken(
payload: Record<string, any>
): Promise<string> {
return this.helperEncryptionService.aes256Encrypt(
payload,
this.permissionTokenEncryptKey,
this.permissionTokenEncryptIv
);
}
async decryptPermissionToken({
data,
}: Record<string, any>): Promise<Record<string, any>> {
return this.helperEncryptionService.aes256Decrypt(
data,
this.permissionTokenEncryptKey,
this.permissionTokenEncryptIv
) as Record<string, any>;
}
async createPermissionToken(
payloadHashed: string | Record<string, any>
): Promise<string> {
return this.helperEncryptionService.jwtEncrypt(
{ data: payloadHashed },
{
secretKey: this.permissionTokenSecretToken,
expiredIn: this.permissionTokenExpirationTime,
notBefore: this.permissionTokenNotBeforeExpirationTime,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
}
);
}
async validatePermissionToken(token: string): Promise<boolean> {
return this.helperEncryptionService.jwtVerify(token, {
secretKey: this.permissionTokenSecretToken,
audience: this.audience,
issuer: this.issuer,
subject: this.subject,
});
}
async payloadPermissionToken(token: string): Promise<Record<string, any>> {
return this.helperEncryptionService.jwtDecrypt(token);
}
async validateUser(
passwordString: string,
passwordHash: string
): Promise<boolean> {
return this.helperHashService.bcryptCompare(
passwordString,
passwordHash
);
}
async createPayloadAccessToken(
data: Record<string, any>,
rememberMe: boolean,
options?: IAuthPayloadOptions
): Promise<Record<string, any>> {
return {
...data,
rememberMe,
loginDate: options?.loginDate ?? this.helperDateService.create(),
};
}
async createPayloadRefreshToken(
_id: string,
rememberMe: boolean,
options?: IAuthPayloadOptions
): Promise<Record<string, any>> {
return {
_id,
rememberMe,
loginDate: options?.loginDate,
};
}
async createPayloadPermissionToken(
data: Record<string, any>
): Promise<Record<string, any>> {
return data;
}
async createPassword(password: string): Promise<IAuthPassword> {
const salt: string = this.helperHashService.randomSalt(20);
const passwordExpired: Date = this.helperDateService.forwardInSeconds(
this.passwordExpiredIn
);
const passwordCreated: Date = this.helperDateService.create();
const passwordHash = this.helperHashService.bcrypt(password, salt);
return {
passwordHash,
passwordExpired,
passwordCreated,
salt,
};
}
async checkPasswordExpired(passwordExpired: Date): Promise<boolean> {
const today: Date = this.helperDateService.create();
const passwordExpiredConvert: Date =
this.helperDateService.create(passwordExpired);
return today > passwordExpiredConvert;
}
async getTokenType(): Promise<string> {
return this.prefixAuthorization;
}
async getAccessTokenExpirationTime(): Promise<number> {
return this.accessTokenExpirationTime;
}
async getRefreshTokenExpirationTime(rememberMe?: boolean): Promise<number> {
return rememberMe
? this.refreshTokenExpirationTimeRememberMe
: this.refreshTokenExpirationTime;
}
async getIssuer(): Promise<string> {
return this.issuer;
}
async getAudience(): Promise<string> {
return this.audience;
}
async getSubject(): Promise<string> {
return this.subject;
}
async getPayloadEncryption(): Promise<boolean> {
return this.payloadEncryption;
}
async getPermissionTokenExpirationTime(): Promise<number> {
return this.permissionTokenExpirationTime;
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AwsS3Service } from './services/aws.s3.service';
@Module({
exports: [AwsS3Service],
providers: [AwsS3Service],
imports: [],
controllers: [],
})
export class AwsModule {}

View File

@ -0,0 +1 @@
export const AwsS3MaxPartNumber = 10000;

View File

@ -0,0 +1,6 @@
import { ObjectCannedACL } from '@aws-sdk/client-s3';
export interface IAwsS3PutItemOptions {
path: string;
acl?: ObjectCannedACL;
}

View File

@ -0,0 +1,63 @@
import {
CompletedPart,
HeadBucketCommandOutput,
UploadPartRequest,
} from '@aws-sdk/client-s3';
import { IAwsS3PutItemOptions } from 'src/common/aws/interfaces/aws.interface';
import {
AwsS3MultipartPartsSerialization,
AwsS3MultipartSerialization,
} from 'src/common/aws/serializations/aws.s3-multipart.serialization';
import { AwsS3Serialization } from 'src/common/aws/serializations/aws.s3.serialization';
import { Readable } from 'stream';
export interface IAwsS3Service {
checkConnection(): Promise<HeadBucketCommandOutput>;
listBucket(): Promise<string[]>;
listItemInBucket(prefix?: string): Promise<AwsS3Serialization[]>;
getItemInBucket(
filename: string,
path?: string
): Promise<Readable | ReadableStream<any> | Blob>;
putItemInBucket(
filename: string,
content:
| string
| Uint8Array
| Buffer
| Readable
| ReadableStream
| Blob,
options?: IAwsS3PutItemOptions
): Promise<AwsS3Serialization>;
deleteItemInBucket(filename: string): Promise<void>;
deleteItemsInBucket(filenames: string[]): Promise<void>;
deleteFolder(dir: string): Promise<void>;
createMultiPart(
filename: string,
options?: IAwsS3PutItemOptions
): Promise<AwsS3MultipartSerialization>;
uploadPart(
path: string,
content: UploadPartRequest['Body'] | string | Uint8Array | Buffer,
uploadId: string,
partNumber: number
): Promise<AwsS3MultipartPartsSerialization>;
completeMultipart(
path: string,
uploadId: string,
parts: CompletedPart[]
): Promise<void>;
abortMultipart(path: string, uploadId: string): Promise<void>;
}

View File

@ -0,0 +1,53 @@
import { faker } from '@faker-js/faker';
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { AwsS3Serialization } from 'src/common/aws/serializations/aws.s3.serialization';
export class AwsS3MultipartPartsSerialization {
@ApiProperty({
example: faker.random.alpha(10),
description: 'ETag from aws after init multipart',
})
@Type(() => String)
ETag: string;
@ApiProperty({
example: 1,
})
@Type(() => Number)
PartNumber: number;
}
export class AwsS3MultipartSerialization extends AwsS3Serialization {
@ApiProperty({
example: faker.random.alpha(20),
description: 'Upload id from aws after init multipart',
})
@Type(() => String)
uploadId: string;
@ApiProperty({
example: 1,
description: 'Last part number uploaded',
})
@Type(() => Number)
partNumber?: number;
@ApiProperty({
example: 200,
description: 'Max part number, or length of the chunk',
})
@Type(() => Number)
maxPartNumber?: number;
@ApiProperty({
oneOf: [
{
$ref: getSchemaPath(AwsS3MultipartPartsSerialization),
type: 'array',
},
],
})
@Type(() => AwsS3MultipartPartsSerialization)
parts?: AwsS3MultipartPartsSerialization[];
}

View File

@ -0,0 +1,41 @@
import { faker } from '@faker-js/faker';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class AwsS3Serialization {
@ApiProperty({
example: faker.system.directoryPath(),
})
@Type(() => String)
path: string;
@ApiProperty({
example: faker.system.filePath(),
})
@Type(() => String)
pathWithFilename: string;
@ApiProperty({
example: faker.system.fileName(),
})
@Type(() => String)
filename: string;
@ApiProperty({
example: `${faker.internet.url()}/${faker.system.filePath()}`,
})
@Type(() => String)
completedUrl: string;
@ApiProperty({
example: faker.internet.url(),
})
@Type(() => String)
baseUrl: string;
@ApiProperty({
example: faker.system.mimeType(),
})
@Type(() => String)
mime: string;
}

View File

@ -0,0 +1,377 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAwsS3PutItemOptions } from 'src/common/aws/interfaces/aws.interface';
import { IAwsS3Service } from 'src/common/aws/interfaces/aws.s3-service.interface';
import {
AwsS3MultipartPartsSerialization,
AwsS3MultipartSerialization,
} from 'src/common/aws/serializations/aws.s3-multipart.serialization';
import { AwsS3Serialization } from 'src/common/aws/serializations/aws.s3.serialization';
import { Readable } from 'stream';
import {
S3Client,
GetObjectCommand,
ListBucketsCommand,
ListObjectsV2Command,
PutObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
ObjectIdentifier,
CreateMultipartUploadCommand,
CreateMultipartUploadCommandInput,
UploadPartCommandInput,
UploadPartCommand,
CompleteMultipartUploadCommandInput,
CompleteMultipartUploadCommand,
CompletedPart,
GetObjectCommandInput,
AbortMultipartUploadCommand,
AbortMultipartUploadCommandInput,
UploadPartRequest,
HeadBucketCommand,
HeadBucketCommandOutput,
ListBucketsOutput,
Bucket,
ListObjectsV2Output,
_Object,
GetObjectOutput,
} from '@aws-sdk/client-s3';
@Injectable()
export class AwsS3Service implements IAwsS3Service {
private readonly s3Client: S3Client;
private readonly bucket: string;
private readonly baseUrl: string;
constructor(private readonly configService: ConfigService) {
this.s3Client = new S3Client({
credentials: {
accessKeyId:
this.configService.get<string>('aws.credential.key'),
secretAccessKey: this.configService.get<string>(
'aws.credential.secret'
),
},
region: this.configService.get<string>('aws.s3.region'),
});
this.bucket = this.configService.get<string>('aws.s3.bucket');
this.baseUrl = this.configService.get<string>('aws.s3.baseUrl');
}
async checkConnection(): Promise<HeadBucketCommandOutput> {
const command: HeadBucketCommand = new HeadBucketCommand({
Bucket: this.bucket,
});
try {
const check: HeadBucketCommandOutput = await this.s3Client.send(
command
);
return check;
} catch (err: any) {
throw err;
}
}
async listBucket(): Promise<string[]> {
const command: ListBucketsCommand = new ListBucketsCommand({});
try {
const listBucket: ListBucketsOutput = await this.s3Client.send(
command
);
const mapList = listBucket.Buckets.map((val: Bucket) => val.Name);
return mapList;
} catch (err: any) {
throw err;
}
}
async listItemInBucket(prefix?: string): Promise<AwsS3Serialization[]> {
const command: ListObjectsV2Command = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix,
});
try {
const listItems: ListObjectsV2Output = await this.s3Client.send(
command
);
const mapList = listItems.Contents.map((val: _Object) => {
const lastIndex: number = val.Key.lastIndexOf('/');
const path: string = val.Key.substring(0, lastIndex);
const filename: string = val.Key.substring(
lastIndex,
val.Key.length
);
const mime: string = filename
.substring(filename.lastIndexOf('.') + 1, filename.length)
.toLocaleUpperCase();
return {
path,
pathWithFilename: val.Key,
filename: filename,
completedUrl: `${this.baseUrl}/${val.Key}`,
baseUrl: this.baseUrl,
mime,
};
});
return mapList;
} catch (err: any) {
throw err;
}
}
async getItemInBucket(
filename: string,
path?: string
): Promise<Readable | ReadableStream<any> | Blob> {
if (path)
path = path.startsWith('/') ? path.replace('/', '') : `${path}`;
const key: string = path ? `${path}/${filename}` : filename;
const input: GetObjectCommandInput = {
Bucket: this.bucket,
Key: key,
};
const command: GetObjectCommand = new GetObjectCommand(input);
try {
const item: GetObjectOutput = await this.s3Client.send(command);
return item.Body;
} catch (err: any) {
throw err;
}
}
async putItemInBucket(
filename: string,
content:
| string
| Uint8Array
| Buffer
| Readable
| ReadableStream
| Blob,
options?: IAwsS3PutItemOptions
): Promise<AwsS3Serialization> {
let path: string = options?.path;
const acl: string = options?.acl ? options.acl : 'public-read';
if (path)
path = path.startsWith('/') ? path.replace('/', '') : `${path}`;
const mime: string = filename
.substring(filename.lastIndexOf('.') + 1, filename.length)
.toUpperCase();
const key: string = path ? `${path}/${filename}` : filename;
const command: PutObjectCommand = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: content,
ACL: acl,
});
try {
await this.s3Client.send(command);
} catch (err: any) {
throw err;
}
return {
path,
pathWithFilename: key,
filename: filename,
completedUrl: `${this.baseUrl}/${key}`,
baseUrl: this.baseUrl,
mime,
};
}
async deleteItemInBucket(filename: string): Promise<void> {
const command: DeleteObjectCommand = new DeleteObjectCommand({
Bucket: this.bucket,
Key: filename,
});
try {
await this.s3Client.send(command);
return;
} catch (err: any) {
throw err;
}
}
async deleteItemsInBucket(filenames: string[]): Promise<void> {
const keys: ObjectIdentifier[] = filenames.map((val) => ({
Key: val,
}));
const command: DeleteObjectsCommand = new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: keys,
},
});
try {
await this.s3Client.send(command);
return;
} catch (err: any) {
throw err;
}
}
async deleteFolder(dir: string): Promise<void> {
const commandList: ListObjectsV2Command = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: dir,
});
const lists = await this.s3Client.send(commandList);
try {
const listItems = lists.Contents.map((val) => ({
Key: val.Key,
}));
const commandDeleteItems: DeleteObjectsCommand =
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: listItems,
},
});
await this.s3Client.send(commandDeleteItems);
const commandDelete: DeleteObjectCommand = new DeleteObjectCommand({
Bucket: this.bucket,
Key: dir,
});
await this.s3Client.send(commandDelete);
return;
} catch (err: any) {
throw err;
}
}
async createMultiPart(
filename: string,
options?: IAwsS3PutItemOptions
): Promise<AwsS3MultipartSerialization> {
let path: string = options?.path;
const acl: string = options?.acl ? options.acl : 'public-read';
if (path)
path = path.startsWith('/') ? path.replace('/', '') : `${path}`;
const mime: string = filename
.substring(filename.lastIndexOf('.') + 1, filename.length)
.toUpperCase();
const key: string = path ? `${path}/${filename}` : filename;
const multiPartInput: CreateMultipartUploadCommandInput = {
Bucket: this.bucket,
Key: key,
ACL: acl,
};
const multiPartCommand: CreateMultipartUploadCommand =
new CreateMultipartUploadCommand(multiPartInput);
try {
const response = await this.s3Client.send(multiPartCommand);
return {
uploadId: response.UploadId,
path,
pathWithFilename: key,
filename: filename,
completedUrl: `${this.baseUrl}/${key}`,
baseUrl: this.baseUrl,
mime,
};
} catch (err: any) {
throw err;
}
}
async uploadPart(
path: string,
content: UploadPartRequest['Body'] | string | Uint8Array | Buffer,
uploadId: string,
partNumber: number
): Promise<AwsS3MultipartPartsSerialization> {
const uploadPartInput: UploadPartCommandInput = {
Bucket: this.bucket,
Key: path,
Body: content,
PartNumber: partNumber,
UploadId: uploadId,
};
const uploadPartCommand: UploadPartCommand = new UploadPartCommand(
uploadPartInput
);
try {
const { ETag } = await this.s3Client.send(uploadPartCommand);
return {
ETag,
PartNumber: partNumber,
};
} catch (err: any) {
throw err;
}
}
async completeMultipart(
path: string,
uploadId: string,
parts: CompletedPart[]
): Promise<void> {
const completeMultipartInput: CompleteMultipartUploadCommandInput = {
Bucket: this.bucket,
Key: path,
UploadId: uploadId,
MultipartUpload: {
Parts: parts,
},
};
const completeMultipartCommand: CompleteMultipartUploadCommand =
new CompleteMultipartUploadCommand(completeMultipartInput);
try {
await this.s3Client.send(completeMultipartCommand);
return;
} catch (err: any) {
throw err;
}
}
async abortMultipart(path: string, uploadId: string): Promise<void> {
const abortMultipartInput: AbortMultipartUploadCommandInput = {
Bucket: this.bucket,
Key: path,
UploadId: uploadId,
};
const abortMultipartCommand: AbortMultipartUploadCommand =
new AbortMultipartUploadCommand(abortMultipartInput);
try {
await this.s3Client.send(abortMultipartCommand);
return;
} catch (err: any) {
throw err;
}
}
}

179
src/common/common.module.ts Normal file
View File

@ -0,0 +1,179 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DebuggerModule } from 'src/common/debugger/debugger.module';
import { HelperModule } from 'src/common/helper/helper.module';
import { ErrorModule } from 'src/common/error/error.module';
import { ResponseModule } from 'src/common/response/response.module';
import { RequestModule } from 'src/common/request/request.module';
import { AuthModule } from 'src/common/auth/auth.module';
import { MessageModule } from 'src/common/message/message.module';
import { LoggerModule } from 'src/common/logger/logger.module';
import { PaginationModule } from 'src/common/pagination/pagination.module';
import Joi from 'joi';
import { ENUM_MESSAGE_LANGUAGE } from './message/constants/message.enum.constant';
import configs from 'src/configs';
import { SettingModule } from 'src/common/setting/setting.module';
import { ApiKeyModule } from 'src/common/api-key/api-key.module';
import { MongooseModule } from '@nestjs/mongoose';
import { DatabaseOptionsService } from 'src/common/database/services/database.options.service';
import { DatabaseOptionsModule } from 'src/common/database/database.options.module';
import { DATABASE_CONNECTION_NAME } from 'src/common/database/constants/database.constant';
import { ENUM_APP_ENVIRONMENT } from 'src/app/constants/app.enum.constant';
import { APP_LANGUAGE } from 'src/app/constants/app.constant';
@Module({
controllers: [],
providers: [],
imports: [
ConfigModule.forRoot({
load: configs,
isGlobal: true,
cache: true,
envFilePath: ['.env'],
expandVariables: true,
validationSchema: Joi.object({
APP_NAME: Joi.string().required(),
APP_ENV: Joi.string()
.valid(...Object.values(ENUM_APP_ENVIRONMENT))
.default('development')
.required(),
APP_LANGUAGE: Joi.string()
.valid(...Object.values(ENUM_MESSAGE_LANGUAGE))
.default(APP_LANGUAGE)
.required(),
HTTP_ENABLE: Joi.boolean().default(true).required(),
HTTP_HOST: [
Joi.string().ip({ version: 'ipv4' }).required(),
Joi.valid('localhost').required(),
],
HTTP_PORT: Joi.number().default(3000).required(),
HTTP_VERSIONING_ENABLE: Joi.boolean().default(true).required(),
HTTP_VERSION: Joi.number().required(),
DEBUGGER_HTTP_WRITE_INTO_FILE: Joi.boolean()
.default(false)
.required(),
DEBUGGER_HTTP_WRITE_INTO_CONSOLE: Joi.boolean()
.default(false)
.required(),
DEBUGGER_SYSTEM_WRITE_INTO_FILE: Joi.boolean()
.default(false)
.required(),
DEBUGGER_SYSTEM_WRITE_INTO_CONSOLE: Joi.boolean()
.default(false)
.required(),
JOB_ENABLE: Joi.boolean().default(false).required(),
DATABASE_HOST: Joi.string()
.default('mongodb://localhost:27017')
.required(),
DATABASE_NAME: Joi.string().default('ack').required(),
DATABASE_USER: Joi.string().allow(null, '').optional(),
DATABASE_PASSWORD: Joi.string().allow(null, '').optional(),
DATABASE_DEBUG: Joi.boolean().default(false).required(),
DATABASE_OPTIONS: Joi.string().allow(null, '').optional(),
AUTH_JWT_SUBJECT: Joi.string().required(),
AUTH_JWT_AUDIENCE: Joi.string().required(),
AUTH_JWT_ISSUER: Joi.string().required(),
AUTH_JWT_ACCESS_TOKEN_SECRET_KEY: Joi.string()
.alphanum()
.min(5)
.max(50)
.required(),
AUTH_JWT_ACCESS_TOKEN_EXPIRED: Joi.string()
.default('15m')
.required(),
AUTH_JWT_REFRESH_TOKEN_SECRET_KEY: Joi.string()
.alphanum()
.min(5)
.max(50)
.required(),
AUTH_JWT_REFRESH_TOKEN_EXPIRED: Joi.string()
.default('7d')
.required(),
AUTH_JWT_REFRESH_TOKEN_REMEMBER_ME_EXPIRED: Joi.string()
.default('30d')
.required(),
AUTH_JWT_REFRESH_TOKEN_NOT_BEFORE_EXPIRATION: Joi.string()
.default('15m')
.required(),
AUTH_PERMISSION_TOKEN_SECRET_KEY: Joi.string()
.alphanum()
.min(5)
.max(50)
.required(),
AUTH_PERMISSION_TOKEN_EXPIRED: Joi.string()
.default('5m')
.required(),
AUTH_JWT_PAYLOAD_ENCRYPT: Joi.boolean()
.default(false)
.required(),
AUTH_JWT_PAYLOAD_ACCESS_TOKEN_ENCRYPT_KEY: Joi.string()
.allow(null, '')
.min(20)
.max(50)
.optional(),
AUTH_JWT_PAYLOAD_ACCESS_TOKEN_ENCRYPT_IV: Joi.string()
.allow(null, '')
.min(16)
.max(50)
.optional(),
AUTH_JWT_PAYLOAD_REFRESH_TOKEN_ENCRYPT_KEY: Joi.string()
.allow(null, '')
.min(20)
.max(50)
.optional(),
AUTH_JWT_PAYLOAD_REFRESH_TOKEN_ENCRYPT_IV: Joi.string()
.allow(null, '')
.min(16)
.max(50)
.optional(),
AUTH_PAYLOAD_PERMISSION_TOKEN_ENCRYPT_KEY: Joi.string()
.allow(null, '')
.min(20)
.max(50)
.optional(),
AUTH_PAYLOAD_PERMISSION_TOKEN_ENCRYPT_IV: Joi.string()
.allow(null, '')
.min(16)
.max(50)
.optional(),
AWS_CREDENTIAL_KEY: Joi.string().allow(null, '').optional(),
AWS_CREDENTIAL_SECRET: Joi.string().allow(null, '').optional(),
AWS_S3_REGION: Joi.string().allow(null, '').optional(),
AWS_S3_BUCKET: Joi.string().allow(null, '').optional(),
}),
validationOptions: {
allowUnknown: true,
abortEarly: true,
},
}),
MongooseModule.forRootAsync({
connectionName: DATABASE_CONNECTION_NAME,
imports: [DatabaseOptionsModule],
inject: [DatabaseOptionsService],
useFactory: (databaseOptionsService: DatabaseOptionsService) =>
databaseOptionsService.createOptions(),
}),
MessageModule,
HelperModule,
PaginationModule,
ErrorModule,
DebuggerModule.forRoot(),
ResponseModule,
RequestModule,
SettingModule,
LoggerModule,
ApiKeyModule,
AuthModule,
],
})
export class CommonModule {}

View File

@ -0,0 +1,21 @@
import { faker } from '@faker-js/faker';
export const DashboardDocQueryStartDate = [
{
name: 'startDate',
allowEmptyValue: true,
required: false,
type: 'string',
example: faker.date.recent().toString(),
},
];
export const DashboardDocQueryEndDate = [
{
name: 'endDate',
allowEmptyValue: true,
required: false,
type: 'string',
example: faker.date.recent().toString(),
},
];

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DashboardService } from 'src/common/dashboard/services/dashboard.service';
@Module({
controllers: [],
providers: [DashboardService],
exports: [DashboardService],
imports: [],
})
export class DashboardModule {}

View File

@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsOptional, ValidateIf } from 'class-validator';
import { MinGreaterThan } from 'src/common/request/validations/request.min-greater-than.validation';
export class DashboardDto {
@ApiProperty({
name: 'startDate',
required: false,
nullable: true,
})
@IsDate()
@IsOptional()
@Type(() => Date)
@ValidateIf((e) => e.startDate !== '' || e.endDate !== '')
startDate?: Date;
@ApiProperty({
name: 'endDate',
required: false,
nullable: true,
})
@IsDate()
@IsOptional()
@MinGreaterThan('startDate')
@Type(() => Date)
@ValidateIf((e) => e.startDate !== '' || e.endDate !== '')
endDate?: Date;
}

View File

@ -0,0 +1,18 @@
export interface IDashboardStartAndEndDate {
startDate: Date;
endDate: Date;
}
export interface IDashboardStartAndEndYear {
startYear: number;
endYear: number;
}
export interface IDashboardStartAndEnd {
month: number;
year: number;
}
export interface IDashboardMonthAndYear extends Partial<IDashboardStartAndEnd> {
total: number;
}

View File

@ -0,0 +1,24 @@
import { DashboardDto } from 'src/common/dashboard/dtos/dashboard';
import {
IDashboardStartAndEnd,
IDashboardStartAndEndDate,
IDashboardStartAndEndYear,
} from 'src/common/dashboard/interfaces/dashboard.interface';
export interface IDashboardService {
getStartAndEndDate(date: DashboardDto): Promise<IDashboardStartAndEndDate>;
getMonths(): Promise<number[]>;
getStartAndEndYear({
startDate,
endDate,
}: IDashboardStartAndEndDate): Promise<IDashboardStartAndEndYear>;
getStartAndEndMonth({
month,
year,
}: IDashboardStartAndEnd): Promise<IDashboardStartAndEndDate>;
getPercentage(value: number, total: number): Promise<number>;
}

View File

@ -0,0 +1,38 @@
import { ApiProperty, OmitType } from '@nestjs/swagger';
export class DashboardMonthAndYearSerialization {
@ApiProperty({
name: 'total',
example: 12,
})
month: number;
@ApiProperty({
name: 'total',
example: 2001,
})
year: number;
@ApiProperty({
name: 'total',
description: 'total of target',
required: true,
nullable: false,
example: 0,
})
total: number;
}
export class DashboardMonthAndYearPercentageSerialization extends OmitType(
DashboardMonthAndYearSerialization,
['total'] as const
) {
@ApiProperty({
name: 'percent',
description: 'Percent of target',
required: true,
nullable: false,
example: 15.4,
})
percent: number;
}

View File

@ -0,0 +1,12 @@
import { faker } from '@faker-js/faker';
import { ApiProperty } from '@nestjs/swagger';
export class DashboardSerialization {
@ApiProperty({
name: 'total',
example: faker.random.numeric(4, { allowLeadingZeros: false }),
description: 'Total user',
nullable: false,
})
total: number;
}

View File

@ -0,0 +1,81 @@
import { DashboardDto } from 'src/common/dashboard/dtos/dashboard';
import {
IDashboardStartAndEnd,
IDashboardStartAndEndDate,
IDashboardStartAndEndYear,
} from 'src/common/dashboard/interfaces/dashboard.interface';
import { IDashboardService } from 'src/common/dashboard/interfaces/dashboard.service.interface';
import { HelperDateService } from 'src/common/helper/services/helper.date.service';
import { HelperNumberService } from 'src/common/helper/services/helper.number.service';
export class DashboardService implements IDashboardService {
constructor(
private readonly helperDateService: HelperDateService,
private readonly helperNumberService: HelperNumberService
) {}
async getStartAndEndDate(
date: DashboardDto
): Promise<IDashboardStartAndEndDate> {
const today = this.helperDateService.create();
let { startDate, endDate } = date;
if (!startDate && !endDate) {
startDate = this.helperDateService.startOfYear(today);
endDate = this.helperDateService.endOfYear(today);
} else {
if (!startDate) {
startDate = this.helperDateService.startOfDay();
} else {
startDate = this.helperDateService.startOfDay(startDate);
}
if (!endDate) {
endDate = this.helperDateService.endOfDay();
} else {
endDate = this.helperDateService.endOfDay(endDate);
}
}
return {
startDate,
endDate,
};
}
async getMonths(): Promise<number[]> {
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
}
async getStartAndEndYear({
startDate,
endDate,
}: IDashboardStartAndEndDate): Promise<IDashboardStartAndEndYear> {
return {
startYear: startDate.getFullYear(),
endYear: endDate.getFullYear(),
};
}
async getStartAndEndMonth({
month,
year,
}: IDashboardStartAndEnd): Promise<IDashboardStartAndEndDate> {
const monthString = `${month}`.padStart(2, '0');
const date: Date = this.helperDateService.create(
`${year}-${monthString}-01`
);
const startDate = this.helperDateService.startOfMonth(date);
const endDate = this.helperDateService.endOfMonth(date);
return {
startDate,
endDate,
};
}
async getPercentage(value: number, total: number): Promise<number> {
return this.helperNumberService.percent(value, total);
}
}

View File

@ -0,0 +1,12 @@
import {
DATABASE_CREATED_AT_FIELD_NAME,
DATABASE_DELETED_AT_FIELD_NAME,
DATABASE_UPDATED_AT_FIELD_NAME,
} from 'src/common/database/constants/database.constant';
export abstract class DatabaseBaseEntityAbstract<T = any> {
abstract _id: T;
abstract [DATABASE_DELETED_AT_FIELD_NAME]?: Date;
abstract [DATABASE_CREATED_AT_FIELD_NAME]?: Date;
abstract [DATABASE_UPDATED_AT_FIELD_NAME]?: Date;
}

View File

@ -0,0 +1,116 @@
import {
IDatabaseCreateOptions,
IDatabaseExistOptions,
IDatabaseFindAllOptions,
IDatabaseFindOneOptions,
IDatabaseOptions,
IDatabaseCreateManyOptions,
IDatabaseManyOptions,
IDatabaseSoftDeleteManyOptions,
IDatabaseRestoreManyOptions,
IDatabaseRawOptions,
} from 'src/common/database/interfaces/database.interface';
export abstract class DatabaseBaseRepositoryAbstract<Entity> {
abstract findAll<T = Entity>(
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<any>
): Promise<T[]>;
abstract findAllDistinct<T = Entity>(
fieldDistinct: string,
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<any>
): Promise<T[]>;
abstract findOne<T = Entity>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<any>
): Promise<T>;
abstract findOneById<T = Entity>(
_id: string,
options?: IDatabaseFindOneOptions<any>
): Promise<T>;
abstract findOneAndLock<T = Entity>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<any>
): Promise<T>;
abstract findOneByIdAndLock<T = Entity>(
_id: string,
options?: IDatabaseFindOneOptions<any>
): Promise<T>;
abstract getTotal(
find?: Record<string, any>,
options?: IDatabaseOptions<any>
): Promise<number>;
abstract exists(
find: Record<string, any>,
options?: IDatabaseExistOptions<any>
): Promise<boolean>;
abstract create<Dto= any>(
data: Dto,
options?: IDatabaseCreateOptions<any>
): Promise<Entity>;
abstract save(repository: Entity): Promise<Entity>;
abstract delete(repository: Entity): Promise<Entity>;
abstract softDelete(repository: Entity): Promise<Entity>;
abstract restore(repository: Entity): Promise<Entity>;
abstract createMany<Dto>(
data: Dto[],
options?: IDatabaseCreateManyOptions<any>
): Promise<boolean>;
abstract deleteManyByIds(
_id: string[],
options?: IDatabaseManyOptions<any>
): Promise<boolean>;
abstract deleteMany(
find: Record<string, any>,
options?: IDatabaseManyOptions<any>
): Promise<boolean>;
abstract softDeleteManyByIds(
_id: string[],
options?: IDatabaseSoftDeleteManyOptions<any>
): Promise<boolean>;
abstract softDeleteMany(
find: Record<string, any>,
options?: IDatabaseSoftDeleteManyOptions<any>
): Promise<boolean>;
abstract restoreManyByIds(
_id: string[],
options?: IDatabaseRestoreManyOptions<any>
): Promise<boolean>;
abstract restoreMany(
find: Record<string, any>,
options?: IDatabaseRestoreManyOptions<any>
): Promise<boolean>;
abstract updateMany<Dto>(
find: Record<string, any>,
data: Dto,
options?: IDatabaseManyOptions<any>
): Promise<boolean>;
abstract raw<RawResponse, RawQuery = any>(
rawOperation: RawQuery,
options?: IDatabaseRawOptions
): Promise<RawResponse[]>;
abstract model(): Promise<any>;
}

View File

@ -0,0 +1,38 @@
import { Prop } from '@nestjs/mongoose';
import { Types } from 'mongoose';
import { DatabaseBaseEntityAbstract } from 'src/common/database/abstracts/database.base-entity.abstract';
import {
DATABASE_CREATED_AT_FIELD_NAME,
DATABASE_DELETED_AT_FIELD_NAME,
DATABASE_UPDATED_AT_FIELD_NAME,
} from 'src/common/database/constants/database.constant';
import { DatabaseDefaultObjectId } from 'src/common/database/constants/database.function.constant';
export abstract class DatabaseMongoObjectIdEntityAbstract extends DatabaseBaseEntityAbstract<Types.ObjectId> {
@Prop({
type: Types.ObjectId,
default: DatabaseDefaultObjectId,
})
_id: Types.ObjectId;
@Prop({
required: false,
index: true,
type: Date,
})
[DATABASE_DELETED_AT_FIELD_NAME]?: Date;
@Prop({
required: false,
index: 'asc',
type: Date,
})
[DATABASE_CREATED_AT_FIELD_NAME]?: Date;
@Prop({
required: false,
index: 'desc',
type: Date,
})
[DATABASE_UPDATED_AT_FIELD_NAME]?: Date;
}

View File

@ -0,0 +1,37 @@
import { Prop } from '@nestjs/mongoose';
import { DatabaseBaseEntityAbstract } from 'src/common/database/abstracts/database.base-entity.abstract';
import {
DATABASE_CREATED_AT_FIELD_NAME,
DATABASE_DELETED_AT_FIELD_NAME,
DATABASE_UPDATED_AT_FIELD_NAME,
} from 'src/common/database/constants/database.constant';
import { DatabaseDefaultUUID } from 'src/common/database/constants/database.function.constant';
export abstract class DatabaseMongoUUIDEntityAbstract extends DatabaseBaseEntityAbstract<string> {
@Prop({
type: String,
default: DatabaseDefaultUUID,
})
_id: string;
@Prop({
required: false,
index: true,
type: Date,
})
[DATABASE_DELETED_AT_FIELD_NAME]?: Date;
@Prop({
required: false,
index: 'asc',
type: Date,
})
[DATABASE_CREATED_AT_FIELD_NAME]?: Date;
@Prop({
required: false,
index: 'desc',
type: Date,
})
[DATABASE_UPDATED_AT_FIELD_NAME]?: Date;
}

View File

@ -0,0 +1,746 @@
import {
ClientSession,
Model,
PipelineStage,
PopulateOptions,
Types,
Document,
} from 'mongoose';
import { DatabaseBaseRepositoryAbstract } from 'src/common/database/abstracts/database.base-repository.abstract';
import { DATABASE_DELETED_AT_FIELD_NAME } from 'src/common/database/constants/database.constant';
import {
IDatabaseCreateOptions,
IDatabaseExistOptions,
IDatabaseFindAllOptions,
IDatabaseFindOneOptions,
IDatabaseOptions,
IDatabaseCreateManyOptions,
IDatabaseManyOptions,
IDatabaseSoftDeleteManyOptions,
IDatabaseRestoreManyOptions,
IDatabaseRawOptions,
} from 'src/common/database/interfaces/database.interface';
export abstract class DatabaseMongoObjectIdRepositoryAbstract<
Entity,
EntityDocument
> extends DatabaseBaseRepositoryAbstract<EntityDocument> {
protected _repository: Model<Entity>;
protected _joinOnFind?: PopulateOptions | PopulateOptions[];
constructor(
repository: Model<Entity>,
options?: PopulateOptions | PopulateOptions[]
) {
super();
this._repository = repository;
this._joinOnFind = options;
}
async findAll<T = Entity>(
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<ClientSession>
): Promise<T[]> {
const findAll = this._repository.find<Entity>(find);
if (options?.withDeleted) {
findAll.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findAll.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findAll.select(options.select);
}
if (options?.paging) {
findAll.limit(options.paging.limit).skip(options.paging.offset);
}
if (options?.order) {
findAll.sort(options.order);
}
if (options?.join) {
findAll.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findAll.session(options.session);
}
return findAll.lean() as any;
}
async findAllDistinct<T = Entity>(
fieldDistinct: string,
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<ClientSession>
): Promise<T[]> {
const findAll = this._repository.distinct<Entity>(
fieldDistinct,
find
);
if (options?.withDeleted) {
findAll.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findAll.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findAll.select(options.select);
}
if (options?.paging) {
findAll.limit(options.paging.limit).skip(options.paging.offset);
}
if (options?.order) {
findAll.sort(options.order);
}
if (options?.join) {
findAll.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findAll.session(options.session);
}
return findAll.lean() as any;
}
async findOne<T = EntityDocument>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findOne<EntityDocument>(find);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async findOneById<T = EntityDocument>(
_id: string,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findById<EntityDocument>(
new Types.ObjectId(_id)
);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async findOneAndLock<T = EntityDocument>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findOneAndUpdate<EntityDocument>(
find,
{
new: true,
useFindAndModify: false,
}
);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as T;
}
async findOneByIdAndLock<T = EntityDocument>(
_id: string,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findByIdAndUpdate(
new Types.ObjectId(_id),
{
new: true,
useFindAndModify: false,
}
);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as T;
}
async getTotal(
find?: Record<string, any>,
options?: IDatabaseOptions<ClientSession>
): Promise<number> {
const count = this._repository.countDocuments(find);
if (options?.withDeleted) {
count.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
count.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.session) {
count.session(options.session);
}
if (options?.join) {
count.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
return count;
}
async exists(
find: Record<string, any>,
options?: IDatabaseExistOptions<ClientSession>
): Promise<boolean> {
if (options?.excludeId) {
find= {
...find,
_id: {
$nin: options?.excludeId.map(
(val) => new Types.ObjectId(val)
) ?? [],
}
}
}
const exist = this._repository.exists(find);
if (options?.withDeleted) {
exist.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
exist.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.session) {
exist.session(options.session);
}
if (options?.join) {
exist.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
const result = await exist;
return result ? true : false;
}
async create<Dto = any>(
data: Dto,
options?: IDatabaseCreateOptions<ClientSession>
): Promise<EntityDocument> {
const dataCreate: Record<string, any> = data;
dataCreate._id = new Types.ObjectId(options?._id);
const created = await this._repository.create([dataCreate], {
session: options ? options.session : undefined,
});
return created[0] as EntityDocument;
}
async save(
repository: EntityDocument & Document<Types.ObjectId>
): Promise<EntityDocument> {
return repository.save();
}
async delete(
repository: EntityDocument & Document<Types.ObjectId>
): Promise<EntityDocument> {
return repository.deleteOne();
}
async softDelete(
repository: EntityDocument &
Document<Types.ObjectId> & { deletedAt?: Date }
): Promise<EntityDocument> {
repository.deletedAt = new Date();
return repository.save();
}
async restore(
repository: EntityDocument &
Document<Types.ObjectId> & { deletedAt?: Date }
): Promise<EntityDocument> {
repository.deletedAt = undefined;
return repository.save();
}
// bulk
async createMany<Dto>(
data: Dto[],
options?: IDatabaseCreateManyOptions<ClientSession>
): Promise<boolean> {
const dataCreate: Record<string, any>[] = data.map(
(val: Record<string, any>) => ({
...val,
_id: new Types.ObjectId(val._id),
})
);
const create = this._repository.insertMany(dataCreate, {
session: options ? options.session : undefined,
});
try {
await create;
return true;
} catch (err: unknown) {
throw err;
}
}
async deleteManyByIds(
_id: string[],
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const del = this._repository.deleteMany({
_id: {
$in: _id.map((val) => new Types.ObjectId(val)),
},
});
if (options?.session) {
del.session(options.session);
}
if (options?.join) {
del.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await del;
return true;
} catch (err: unknown) {
throw err;
}
}
async deleteMany(
find: Record<string, any>,
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const del = this._repository.deleteMany(find);
if (options?.session) {
del.session(options.session);
}
if (options?.join) {
del.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await del;
return true;
} catch (err: unknown) {
throw err;
}
}
async softDeleteManyByIds(
_id: string[],
options?: IDatabaseSoftDeleteManyOptions<ClientSession>
): Promise<boolean> {
const softDel = this._repository
.updateMany(
{
_id: {
$in: _id.map((val) => new Types.ObjectId(val)),
},
},
{
$set: {
deletedAt: new Date(),
},
}
)
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
softDel.session(options.session);
}
if (options?.join) {
softDel.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await softDel;
return true;
} catch (err: unknown) {
throw err;
}
}
async softDeleteMany(
find: Record<string, any>,
options?: IDatabaseSoftDeleteManyOptions<ClientSession>
): Promise<boolean> {
const softDel = this._repository
.updateMany(find, {
$set: {
deletedAt: new Date(),
},
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
softDel.session(options.session);
}
if (options?.join) {
softDel.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await softDel;
return true;
} catch (err: unknown) {
throw err;
}
}
async restoreManyByIds(
_id: string[],
options?: IDatabaseRestoreManyOptions<ClientSession>
): Promise<boolean> {
const rest = this._repository
.updateMany(
{
_id: {
$in: _id.map((val) => new Types.ObjectId(val)),
},
},
{
$set: {
deletedAt: undefined,
},
}
)
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(true);
if (options?.session) {
rest.session(options.session);
}
if (options?.join) {
rest.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await rest;
return true;
} catch (err: unknown) {
throw err;
}
}
async restoreMany(
find: Record<string, any>,
options?: IDatabaseRestoreManyOptions<ClientSession>
): Promise<boolean> {
const rest = this._repository
.updateMany(find, {
$set: {
deletedAt: undefined,
},
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(true);
if (options?.session) {
rest.session(options.session);
}
if (options?.join) {
rest.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await rest;
return true;
} catch (err: unknown) {
throw err;
}
}
async updateMany<Dto>(
find: Record<string, any>,
data: Dto,
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const update = this._repository
.updateMany(find, {
$set: data,
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
update.session(options.session as ClientSession);
}
if (options?.join) {
update.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await update;
return true;
} catch (err: unknown) {
throw err;
}
}
// raw
async raw<RawResponse, RawQuery = PipelineStage[]>(
rawOperation: RawQuery,
options?: IDatabaseRawOptions
): Promise<RawResponse[]> {
if (!Array.isArray(rawOperation)) {
throw new Error('Must in array');
}
const pipeline: PipelineStage[] = rawOperation;
if (options?.withDeleted) {
pipeline.push({
$match: {
$or: [
{
[DATABASE_DELETED_AT_FIELD_NAME]: {
$exists: false,
},
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
],
},
});
} else {
pipeline.push({
$match: {
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
});
}
const aggregate = this._repository.aggregate<RawResponse>(pipeline);
if (options?.session) {
aggregate.session(options?.session);
}
return aggregate;
}
async model(): Promise<Model<Entity>> {
return this._repository;
}
}

View File

@ -0,0 +1,734 @@
import {
ClientSession,
Model,
PipelineStage,
PopulateOptions,
Document,
} from 'mongoose';
import { DatabaseBaseRepositoryAbstract } from 'src/common/database/abstracts/database.base-repository.abstract';
import { DATABASE_DELETED_AT_FIELD_NAME } from 'src/common/database/constants/database.constant';
import {
IDatabaseCreateOptions,
IDatabaseExistOptions,
IDatabaseFindAllOptions,
IDatabaseFindOneOptions,
IDatabaseOptions,
IDatabaseCreateManyOptions,
IDatabaseManyOptions,
IDatabaseSoftDeleteManyOptions,
IDatabaseRestoreManyOptions,
IDatabaseRawOptions,
} from 'src/common/database/interfaces/database.interface';
export abstract class DatabaseMongoUUIDRepositoryAbstract<
Entity,
EntityDocument
> extends DatabaseBaseRepositoryAbstract<EntityDocument> {
protected _repository: Model<Entity>;
protected _joinOnFind?: PopulateOptions | PopulateOptions[];
constructor(
repository: Model<Entity>,
options?: PopulateOptions | PopulateOptions[]
) {
super();
this._repository = repository;
this._joinOnFind = options;
}
async findAll<T = EntityDocument>(
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<ClientSession>
): Promise<T[]> {
const findAll = this._repository.find<EntityDocument>(find);
if (options?.withDeleted) {
findAll.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findAll.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findAll.select(options.select);
}
if (options?.paging) {
findAll.limit(options.paging.limit).skip(options.paging.offset);
}
if (options?.order) {
findAll.sort(options.order);
}
if (options?.join) {
findAll.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findAll.session(options.session);
}
return findAll.lean() as any;
}
async findAllDistinct<T = EntityDocument>(
fieldDistinct: string,
find?: Record<string, any>,
options?: IDatabaseFindAllOptions<ClientSession>
): Promise<T[]> {
const findAll = this._repository.distinct<EntityDocument>(
fieldDistinct,
find
);
if (options?.withDeleted) {
findAll.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findAll.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findAll.select(options.select);
}
if (options?.paging) {
findAll.limit(options.paging.limit).skip(options.paging.offset);
}
if (options?.order) {
findAll.sort(options.order);
}
if (options?.join) {
findAll.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findAll.session(options.session);
}
return findAll.lean() as any;
}
async findOne<T = EntityDocument>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findOne<EntityDocument>(find);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async findOneById<T = EntityDocument>(
_id: string,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findById<EntityDocument>(_id);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async findOneAndLock<T = EntityDocument>(
find: Record<string, any>,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findOneAndUpdate<EntityDocument>(
find,
{
new: true,
useFindAndModify: false,
}
);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async findOneByIdAndLock<T = EntityDocument>(
_id: string,
options?: IDatabaseFindOneOptions<ClientSession>
): Promise<T> {
const findOne = this._repository.findByIdAndUpdate<EntityDocument>(
_id,
{
new: true,
useFindAndModify: false,
}
);
if (options?.withDeleted) {
findOne.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
findOne.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.select) {
findOne.select(options.select);
}
if (options?.join) {
findOne.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
if (options?.session) {
findOne.session(options.session);
}
if (options?.order) {
findOne.sort(options.order);
}
return findOne.exec() as any;
}
async getTotal(
find?: Record<string, any>,
options?: IDatabaseOptions<ClientSession>
): Promise<number> {
const count = this._repository.countDocuments(find);
if (options?.withDeleted) {
count.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
count.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.session) {
count.session(options.session);
}
if (options?.join) {
count.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
return count;
}
async exists(
find: Record<string, any>,
options?: IDatabaseExistOptions<ClientSession>
): Promise<boolean> {
if (options?.excludeId) {
find = {
...find,
_id: {
$nin: options?.excludeId ?? [],
},
};
}
const exist = this._repository.exists(find);
if (options?.withDeleted) {
exist.or([
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
]);
} else {
exist.where(DATABASE_DELETED_AT_FIELD_NAME).exists(false);
}
if (options?.session) {
exist.session(options.session);
}
if (options?.join) {
exist.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
const result = await exist;
return result ? true : false;
}
async create<Dto = any>(
data: Dto,
options?: IDatabaseCreateOptions<ClientSession>
): Promise<EntityDocument> {
const dataCreate: Record<string, any> = data;
if (options?._id) {
dataCreate._id = options._id;
}
const created = await this._repository.create([dataCreate], {
session: options ? options.session : undefined,
});
return created[0] as EntityDocument;
}
async save(
repository: EntityDocument & Document<string>
): Promise<EntityDocument> {
return repository.save();
}
async delete(
repository: EntityDocument & Document<string>
): Promise<EntityDocument> {
return repository.deleteOne();
}
async softDelete(
repository: EntityDocument & Document<string> & { deletedAt?: Date }
): Promise<EntityDocument> {
repository.deletedAt = new Date();
return repository.save();
}
async restore(
repository: EntityDocument & Document<string> & { deletedAt?: Date }
): Promise<EntityDocument> {
repository.deletedAt = undefined;
return repository.save();
}
// bulk
async createMany<Dto>(
data: Dto[],
options?: IDatabaseCreateManyOptions<ClientSession>
): Promise<boolean> {
const create = this._repository.insertMany(data, {
session: options ? options.session : undefined,
});
try {
await create;
return true;
} catch (err: unknown) {
throw err;
}
}
async deleteManyByIds(
_id: string[],
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const del = this._repository.deleteMany({
_id: {
$in: _id,
},
});
if (options?.session) {
del.session(options.session);
}
if (options?.join) {
del.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await del;
return true;
} catch (err: unknown) {
throw err;
}
}
async deleteMany(
find: Record<string, any>,
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const del = this._repository.deleteMany(find);
if (options?.session) {
del.session(options.session);
}
if (options?.join) {
del.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await del;
return true;
} catch (err: unknown) {
throw err;
}
}
async softDeleteManyByIds(
_id: string[],
options?: IDatabaseSoftDeleteManyOptions<ClientSession>
): Promise<boolean> {
const softDel = this._repository
.updateMany(
{
_id: {
$in: _id,
},
},
{
$set: {
deletedAt: new Date(),
},
}
)
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
softDel.session(options.session);
}
if (options?.join) {
softDel.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await softDel;
return true;
} catch (err: unknown) {
throw err;
}
}
async softDeleteMany(
find: Record<string, any>,
options?: IDatabaseSoftDeleteManyOptions<ClientSession>
): Promise<boolean> {
const softDel = this._repository
.updateMany(find, {
$set: {
deletedAt: new Date(),
},
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
softDel.session(options.session);
}
if (options?.join) {
softDel.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await softDel;
return true;
} catch (err: unknown) {
throw err;
}
}
async restoreManyByIds(
_id: string[],
options?: IDatabaseRestoreManyOptions<ClientSession>
): Promise<boolean> {
const rest = this._repository
.updateMany(
{
_id: {
$in: _id,
},
},
{
$set: {
deletedAt: undefined,
},
}
)
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(true);
if (options?.session) {
rest.session(options.session);
}
if (options?.join) {
rest.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await rest;
return true;
} catch (err: unknown) {
throw err;
}
}
async restoreMany(
find: Record<string, any>,
options?: IDatabaseRestoreManyOptions<ClientSession>
): Promise<boolean> {
const rest = this._repository
.updateMany(find, {
$set: {
deletedAt: undefined,
},
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(true);
if (options?.session) {
rest.session(options.session);
}
if (options?.join) {
rest.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await rest;
return true;
} catch (err: unknown) {
throw err;
}
}
async updateMany<Dto>(
find: Record<string, any>,
data: Dto,
options?: IDatabaseManyOptions<ClientSession>
): Promise<boolean> {
const update = this._repository
.updateMany(find, {
$set: data,
})
.where(DATABASE_DELETED_AT_FIELD_NAME)
.exists(false);
if (options?.session) {
update.session(options.session as ClientSession);
}
if (options?.join) {
update.populate(
typeof options.join === 'boolean'
? this._joinOnFind
: (options.join as PopulateOptions | PopulateOptions[])
);
}
try {
await update;
return true;
} catch (err: unknown) {
throw err;
}
}
async raw<RawResponse, RawQuery = PipelineStage[]>(
rawOperation: RawQuery,
options?: IDatabaseRawOptions
): Promise<RawResponse[]> {
if (!Array.isArray(rawOperation)) {
throw new Error('Must in array');
}
const pipeline: PipelineStage[] = rawOperation;
if (options?.withDeleted) {
pipeline.push({
$match: {
$or: [
{
[DATABASE_DELETED_AT_FIELD_NAME]: {
$exists: false,
},
},
{
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: true },
},
],
},
});
} else {
pipeline.push({
$match: {
[DATABASE_DELETED_AT_FIELD_NAME]: { $exists: false },
},
});
}
const aggregate = this._repository.aggregate<RawResponse>(pipeline);
if (options?.session) {
aggregate.session(options?.session);
}
return aggregate;
}
async model(): Promise<Model<Entity>> {
return this._repository;
}
}

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