├── .commitlintrc.json ├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── setvars │ │ └── action.yml ├── variables │ └── myvars.env └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── Procfile ├── README.md ├── bin └── cli.js ├── config ├── default.yml ├── development.yml ├── production.yml └── test.yml ├── docker-compose-test.yml ├── docker-compose.yml ├── index.js ├── nest-cli.json ├── package.json ├── public └── images │ ├── logo.svg │ └── profile │ └── .gitignore ├── src ├── app.controller.ts ├── app.module.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── dto │ │ ├── change-password.dto.ts │ │ ├── create-user.dto.ts │ │ ├── forget-password.dto.ts │ │ ├── jwt-payload.dto.ts │ │ ├── register-user.dto.ts │ │ ├── reset-password.dto.ts │ │ ├── update-user-profile.dto.ts │ │ ├── update-user.dto.ts │ │ ├── user-login.dto.ts │ │ └── user-search-filter.dto.ts │ ├── entity │ │ └── user.entity.ts │ ├── pipes │ │ └── username-unique-validation.pipes.ts │ ├── serializer │ │ └── user.serializer.ts │ ├── user-status.enum.ts │ └── user.repository.ts ├── common │ ├── constants │ │ ├── exception-title-list.constants.ts │ │ ├── status-codes-list.constants.ts │ │ └── validation-errors-list.constants.ts │ ├── decorators │ │ ├── get-user.decorator.ts │ │ ├── is-equal-to.decorator.ts │ │ └── sanitize-user.decorators.ts │ ├── entity │ │ └── custom-base.entity.ts │ ├── exception │ │ └── exception-filter.ts │ ├── extra │ │ └── common-search-field.dto.ts │ ├── guard │ │ ├── custom-throttle.guard.ts │ │ ├── jwt-auth.guard.ts │ │ ├── jwt-two-factor.guard.ts │ │ └── permission.guard.ts │ ├── helper │ │ ├── generate-code.helper.ts │ │ └── multer-options.helper.ts │ ├── interfaces │ │ ├── common-dto.interface.ts │ │ ├── common-service.interface.ts │ │ ├── search-filter.interface.ts │ │ └── validation-error.interface.ts │ ├── middleware │ │ └── logger.middleware.ts │ ├── pipes │ │ ├── abstract-unique-validator.ts │ │ ├── custom-validation.pipe.ts │ │ ├── i18n-exception-filter.pipe.ts │ │ └── unique-validator.pipe.ts │ ├── repository │ │ └── base.repository.ts │ ├── serializer │ │ └── model.serializer.ts │ └── strategy │ │ ├── jwt-two-factor.strategy.ts │ │ └── jwt.strategy.ts ├── config │ ├── email-template.ts │ ├── ormconfig.ts │ ├── permission-config.ts │ ├── throttle-config.ts │ └── winston.ts ├── dashboard │ ├── dashboard.controller.ts │ ├── dashboard.module.ts │ ├── dashboard.service.ts │ ├── dto │ │ ├── create-dashboard.dto.ts │ │ └── update-dashboard.dto.ts │ ├── entities │ │ └── dashboard.entity.ts │ └── interface │ │ ├── browser-stats.interface.ts │ │ ├── os-stats.interface.ts │ │ └── user-stats.interface.ts ├── database │ ├── migrations │ │ ├── 1614275766942-RoleTable.ts │ │ ├── 1614275788549-PermissionTable.ts │ │ ├── 1614275796207-PermissionRoleTable.ts │ │ ├── 1614275816426-UserTable.ts │ │ ├── 1617559216655-addTokenValidityDateInUserEntity.ts │ │ ├── 1622305543735-EmailTemplate.ts │ │ ├── 1623601947397-CreateRefreshTokenTable.ts │ │ ├── 1623777103308-AddUserAgentRefreshTokenTable.ts │ │ ├── 1626924978575-AddAvatarColumnUserTable.ts │ │ ├── 1627278359782-Add2faColumnsUserTable.ts │ │ ├── 1627736950484-AddTwoSecretGenerateThrottleTime.ts │ │ └── 1629136129718-AddBrowserAndOsColumnRefreshTokenTable.ts │ └── seeds │ │ ├── create-email-template.seed.ts │ │ ├── create-permission.seed.ts │ │ ├── create-role.seed.ts │ │ └── create-user.seed.ts ├── email-template │ ├── dto │ │ ├── create-email-template.dto.ts │ │ ├── email-templates-search-filter.dto.ts │ │ └── update-email-template.dto.ts │ ├── email-template.controller.ts │ ├── email-template.module.ts │ ├── email-template.repository.ts │ ├── email-template.service.ts │ ├── entities │ │ └── email-template.entity.ts │ └── serializer │ │ └── email-template.serializer.ts ├── exception │ ├── custom-http.exception.ts │ ├── forbidden.exception.ts │ ├── not-found.exception.ts │ └── unauthorized.exception.ts ├── i18n │ ├── en │ │ ├── app.json │ │ ├── exception.json │ │ └── validation.json │ └── ne │ │ ├── app.json │ │ ├── exception.json │ │ └── validation.json ├── mail │ ├── interface │ │ └── mail-job.interface.ts │ ├── mail.module.ts │ ├── mail.processor.ts │ ├── mail.service.ts │ └── templates │ │ └── email │ │ ├── activate-account.pug │ │ ├── assets │ │ └── css │ │ │ └── style.css │ │ ├── layouts │ │ └── email-layout.pug │ │ ├── mixins │ │ └── _button.pug │ │ ├── partials │ │ └── footer.pug │ │ └── password-reset.pug ├── main.ts ├── paginate │ ├── index.ts │ ├── pagination-info.interface.ts │ ├── pagination.results.interface.ts │ └── pagination.ts ├── permission │ ├── dto │ │ ├── create-permission.dto.ts │ │ ├── permission-filter.dto.ts │ │ └── update-permission.dto.ts │ ├── entities │ │ └── permission.entity.ts │ ├── misc │ │ └── load-permission.misc.ts │ ├── permission.repository.ts │ ├── permissions.controller.ts │ ├── permissions.module.ts │ ├── permissions.service.ts │ └── serializer │ │ └── permission.serializer.ts ├── refresh-token │ ├── dto │ │ └── refresh-paginate-filter.dto.ts │ ├── entities │ │ └── refresh-token.entity.ts │ ├── interface │ │ └── refresh-token.interface.ts │ ├── refresh-token.module.ts │ ├── refresh-token.repository.ts │ ├── refresh-token.service.ts │ └── serializer │ │ └── refresh-token.serializer.ts ├── role │ ├── dto │ │ ├── create-role.dto.ts │ │ ├── role-filter.dto.ts │ │ └── update-role.dto.ts │ ├── entities │ │ └── role.entity.ts │ ├── role.repository.ts │ ├── roles.controller.ts │ ├── roles.module.ts │ ├── roles.service.ts │ └── serializer │ │ └── role.serializer.ts └── twofa │ ├── dto │ ├── twofa-code.dto.ts │ └── twofa-status-update.dto.ts │ ├── twofa.controller.ts │ ├── twofa.module.ts │ └── twofa.service.ts ├── test ├── e2e │ ├── app │ │ └── app.e2e-spec.ts │ ├── auth │ │ └── auth.e2e-spec.ts │ ├── example.e2e-spec.ts │ └── jest-e2e.json ├── factories │ ├── app.ts │ ├── role.factory.ts │ ├── throttle.ts │ └── user.factory.ts ├── unit │ ├── auth │ │ ├── auth.service.unit-spec.ts │ │ ├── entity │ │ │ └── user.entity.unit-spec.ts │ │ ├── pipes │ │ │ └── username-unique-validation.pipes.unit-spec.ts │ │ └── user.repository.unit-spec.ts │ ├── common │ │ ├── guard │ │ │ └── permission.guard.unit-spec.ts │ │ ├── pipes │ │ │ └── unique-validator.pipe.unit-spec.ts │ │ ├── repository │ │ │ └── base.repository.unit-spec.ts │ │ └── strategy │ │ │ └── jwt.strategy.unit-spec.ts │ ├── dashboard │ │ └── dashboard.service.unit-spec.ts │ ├── email-template │ │ └── email-template.service.unit-spec.ts │ ├── jest-unit.json │ ├── permission │ │ └── permissions.service.unit-spec.ts │ ├── refresh-token │ │ ├── refresh-token.repository.unit-spec.ts │ │ └── refresh-token.service.unit-spec.ts │ ├── role │ │ └── roles.service.unit-spec.ts │ └── twofa │ │ └── twofa.service.unit-spec.ts └── utility │ ├── create-mock.ts │ └── extract-cookie.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Versioning and metadata 2 | .git 3 | .gitignore 4 | .dockerignore 5 | 6 | # Build dependencies 7 | dist 8 | node_modules 9 | coverage 10 | 11 | # Environment (contains sensitive data) 12 | .env 13 | 14 | # Files not required for production 15 | .editorconfig 16 | Dockerfile 17 | README.md 18 | tslint.json 19 | nodemon.json 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SERVER_PORT=7777 2 | DB_HOST= 3 | DB_PASSWORD= 4 | DB_USERNAME= 5 | DB_DATABASE_NAME= 6 | DB_PORT=5432 7 | REDIS_PORT=6399 -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module' 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended' 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off' 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Call function '...' 16 | 2. Pass value '...' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/actions/setvars/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Set environment variables' 2 | description: 'Configures environment variables for a workflow' 3 | inputs: 4 | varFilePath: 5 | description: 'File path to variable file or directory. Defaults to ./.github/variables/* if none specified and runs against each file in that directory.' 6 | required: false 7 | default: ./.github/variables/* 8 | runs: 9 | using: "composite" 10 | steps: 11 | - run: | 12 | sed "" ${{ inputs.varFilePath }} >> $GITHUB_ENV 13 | shell: bash -------------------------------------------------------------------------------- /.github/variables/myvars.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | DB_TYPE=postgres 3 | DB_HOST=localhost 4 | DB_PORT=5432 5 | DB_DATABASE_NAME=truthy_db 6 | DB_USERNAME=truthy_user 7 | DB_PASSWORD=truthypwd -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration Testing 2 | 3 | on: push 4 | 5 | jobs: 6 | unit-tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [16.x] 11 | test: [truthy-cms] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use NodeJS ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | - name: Setup yarn 21 | run: npm install -g yarn 22 | 23 | - name: Setup Nodejs with yarn caching 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: yarn 28 | 29 | - name: Install dependencies 30 | run: yarn 31 | 32 | - name: Run unit test 33 | run: yarn test:unit 34 | 35 | e2e-test: 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | node-version: [16.x] 40 | needs: [unit-tests] 41 | steps: 42 | - uses: actions/checkout@v1 43 | - name: Use Node.js ${{ matrix.node-version }} 44 | uses: actions/setup-node@v2 45 | with: 46 | node-version: ${{ matrix.node-version }} 47 | - name: Setup yarn 48 | run: npm install -g yarn 49 | 50 | - name: Setup Nodejs with yarn caching 51 | uses: actions/setup-node@v2 52 | with: 53 | node-version: ${{ matrix.node-version }} 54 | cache: yarn 55 | - name: Set Environment Variables 56 | uses: ./.github/actions/setvars 57 | with: 58 | varFilePath: ./.github/variables/myvars.env 59 | - name: Start Docker-Compose 60 | run: docker-compose -f docker-compose-test.yml up -d 61 | - name: Install dependencies 62 | run: yarn 63 | - name: Run tests 64 | run: yarn test:e2e 65 | - name: Stop Docker-Compose 66 | run: docker-compose -f docker-compose-test.yml down 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | dump.rdb 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | .env 37 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run precommit 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run prepush -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "{src,apps,libs,test}/**/*.ts": ["npm run lint"], 3 | "./**/*.{ts,js,json,*rc}": ["npm run prettier:write"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'stable' 4 | git: 5 | depth: 5 6 | before_install: 7 | - npm i -g npm@latest 8 | - npm i -g yarn 9 | install: 10 | - yarn 11 | script: 12 | - yarn run test 13 | #after_success: yarn run coveralls 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.jsxSingleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at roshanranabhat11@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Transcriptase 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | - Becoming a maintainer 10 | 11 | ## Any contributions you make will be under the MIT Software License 12 | 13 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 14 | 15 | ## Report bugs using Github's [issues](https://github.com/gobeam/truthy/issues) 16 | 17 | A relevant coding style guideline is the [Go Code Review Comments](https://code.google.com/p/go-wiki/wiki/CodeReviewComments). 18 | 19 | ## Documentation 20 | 21 | If you contribute anything that changes the behavior of the application, 22 | document it in the follow places as applicable: 23 | 24 | - the code itself, through clear comments and unit tests 25 | - [README](README.md) 26 | 27 | This includes new features, additional variants of behavior, and breaking 28 | changes. 29 | 30 | ## Testing 31 | 32 | [Jest](https://jestjs.io/) is used as testing framework, and are run prior to 33 | the PR being accepted. 34 | 35 | ## Issues 36 | 37 | For creating an issue: 38 | 39 | - **Bugs:** please be as thorough as possible, with steps to recreate the issue 40 | and any other relevant information. 41 | - **Feature Requests:** please include functionality and use cases. If this is 42 | an extension of a current feature, please include whether or not this would 43 | be a breaking change or how to extend the feature with backwards 44 | compatibility. 45 | 46 | If you wish to work on an issue, please assign it to yourself. If you have any 47 | questions regarding implementation, feel free to ask clarifying questions on 48 | the issue itself. 49 | 50 | ## Pull Requests 51 | 52 | - should be narrowly focused with no more than 3 or 4 logical commits 53 | - when possible, address no more than one issue 54 | - should be reviewable in the GitHub code review tool 55 | - should be linked to any issues it relates to (i.e. issue number after (#) in commit messages or pull request message) 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine As development 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # COPY package*.json ./ 6 | COPY package.json ./ 7 | 8 | COPY yarn.lock ./ 9 | 10 | RUN yarn install 11 | 12 | COPY . . 13 | 14 | RUN yarn build 15 | 16 | FROM node:14-alpine As production 17 | 18 | ARG NODE_ENV=production 19 | ENV NODE_ENV=${NODE_ENV} 20 | 21 | WORKDIR /usr/src/app 22 | 23 | COPY package.json ./ 24 | 25 | COPY yarn.lock ./ 26 | 27 | RUN yarn install --production 28 | 29 | COPY . . 30 | 31 | COPY --from=development /usr/src/app/dist ./dist 32 | 33 | CMD ["node", "dist/main"] -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine As development 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --only=development 8 | 9 | COPY . . 10 | 11 | RUN npm run start:dev -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | # Checklist: 24 | 25 | - [ ] My code follows the style guidelines of this project 26 | - [ ] I have performed a self-review of my own code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] I have made corresponding changes to the documentation 29 | - [ ] My changes generate no new warnings 30 | - [ ] I have added tests that prove my fix is effective or that my feature works 31 | - [ ] New and existing unit tests pass locally with my changes 32 | - [ ] Any dependent changes have been merged and published in downstream modules 33 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start:prod 2 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { execSync } = require('child_process'); 3 | 4 | const RESET = '\x1b[0m'; 5 | const GREEN = '\x1b[32m'; 6 | const YELLOW = '\x1b[33m'; 7 | const WHITE = '\x1b[37m'; 8 | const RED = '\x1b[31m'; 9 | const CYAN = '\x1b[36m'; 10 | const MAGENTA = '\x1b[35m'; 11 | 12 | const getColoredText = (text, color) => { 13 | if (color == null) { 14 | // eslint-disable-next-line no-param-reassign 15 | color = WHITE; 16 | } 17 | return color + text + RESET; 18 | }; 19 | 20 | const runCommand = (command) => { 21 | try { 22 | execSync(`${command}`, { stdio: 'inherit' }); 23 | } catch (e) { 24 | console.error( 25 | getColoredText(`Failed to execute ${command}. Error: ${e}`, RED) 26 | ); 27 | return false; 28 | } 29 | return true; 30 | }; 31 | 32 | const repoName = process.argv[2]; 33 | const gitCheckoutCommand = `git clone --depth 1 https://github.com/gobeam/truthy ${repoName}`; 34 | const installDepsCommand = `cd ${repoName} && yarn install`; 35 | 36 | console.log( 37 | getColoredText(`Cloning the repository with name ${repoName}`, CYAN) 38 | ); 39 | const checkedOut = runCommand(gitCheckoutCommand); 40 | if (!checkedOut) process.exit(-1); 41 | 42 | console.log(getColoredText(`Installing dependencies for ${repoName}`, YELLOW)); 43 | const installedDeps = runCommand(installDepsCommand); 44 | if (!installedDeps) process.exit(-1); 45 | 46 | console.log( 47 | getColoredText( 48 | 'Congratulations! You are ready. Follow the following commands to start', 49 | MAGENTA 50 | ) 51 | ); 52 | console.log(getColoredText(`cd ${repoName} && yarn start:dev`, GREEN)); 53 | -------------------------------------------------------------------------------- /config/default.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 7777 3 | origin: 'http://localhost:3000' 4 | 5 | db: 6 | host: 'localhost' 7 | type: 'postgres' 8 | port: 5432 9 | username: 'postgres' 10 | password: 'root' 11 | synchronize: false 12 | 13 | jwt: 14 | # expiresIn: 10 15 | expiresIn: 900 16 | refreshExpiresIn: 604800 17 | cookieExpiresIn: 604800 18 | 19 | app: 20 | fallbackLanguage: 'en' 21 | name: 'Truthy' 22 | version: 'v0.1' 23 | description: 'Official Truthy API' 24 | appUrl: 'http://localhost:7777' 25 | frontendUrl: 'http://localhost:3000' 26 | sameSite: true 27 | 28 | mail: 29 | host: 'smtp.mailtrap.io' 30 | port: 2525 31 | user: 'f4a511d60957e6' 32 | pass: '7522797b96cef0' 33 | from: 'truthycms' 34 | fromMail: 'truthycms@gmail.com' 35 | preview: true 36 | secure: false 37 | ignoreTLS: true 38 | queueName: 'truthy-mail' 39 | 40 | queue: 41 | driver: 'redis' 42 | host: 'localhost' 43 | port: 6379 44 | db: '' 45 | password: '' 46 | username: '' 47 | 48 | throttle: 49 | global: 50 | ttl: 60 51 | limit: 60 52 | login: 53 | prefix: 'login_fail_throttle' 54 | limit: 5 55 | duration: 2592000 56 | blockDuration: 3000 57 | 58 | twofa: 59 | authenticationAppNAme: 'truthy' 60 | 61 | winston: 62 | groupName: 'truthy' 63 | streamName: 'truthy-stream' 64 | awsAccessKeyId: '' 65 | awsSecretAccessKey: '' 66 | awsRegion: '' 67 | 68 | -------------------------------------------------------------------------------- /config/development.yml: -------------------------------------------------------------------------------- 1 | db: 2 | host: 'localhost' 3 | type: 'postgres' 4 | port: 5432 5 | username: 'postgres' 6 | password: 'root' 7 | synchronize: false 8 | 9 | jwt: 10 | secret: 'example@123' 11 | -------------------------------------------------------------------------------- /config/production.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 7777 3 | origin: 'http://localhost:3000' 4 | 5 | db: 6 | host: '' 7 | type: '' 8 | port: 5432 9 | database: 'truthy' 10 | synchronize: false 11 | 12 | jwt: 13 | expiresIn: 900 14 | refreshExpiresIn: 604800 15 | cookieExpiresIn: 604800 16 | 17 | app: 18 | name: 'Truthy' 19 | version: 'v0.1' 20 | description: 'Official Truthy API' 21 | appUrl: 'https://truthy-backend.herokuapp.com/' 22 | frontendUrl: 'http://localhost:3000' 23 | 24 | mail: 25 | host: '' 26 | port: 2525 27 | user: '' 28 | pass: '' 29 | from: 'truthycms' 30 | fromMail: 'truthycms@gmail.com' 31 | preview: true 32 | secure: false 33 | ignoreTLS: true 34 | queueName: 'truthy-mail' 35 | 36 | queue: 37 | driver: 'redis' 38 | host: 'localhost' 39 | port: 6379 40 | db: '' 41 | password: '' 42 | username: '' 43 | 44 | throttle: 45 | global: 46 | ttl: 60 47 | limit: 60 48 | login: 49 | prefix: 'login_fail_throttle' 50 | limit: 5 51 | duration: 2592000 52 | blockDuration: 3000 53 | -------------------------------------------------------------------------------- /config/test.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: 'example@123' 3 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | truthy-postgres: 4 | image: postgres 5 | ports: 6 | - '5432:5432' 7 | environment: 8 | - POSTGRES_USER=truthy_user 9 | - POSTGRES_PASSWORD=truthypwd 10 | - POSTGRES_DB=truthy_db 11 | 12 | redis: 13 | image: redis 14 | ports: 15 | - '6379:6379' 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | main: 5 | container_name: main 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | volumes: 10 | - .:/usr/src/app 11 | - /usr/src/app/node_modules 12 | ports: 13 | - ${SERVER_PORT}:${SERVER_PORT} 14 | env_file: 15 | - .env 16 | networks: 17 | - webnet 18 | depends_on: 19 | - postgres 20 | 21 | postgres: 22 | container_name: postgres 23 | image: postgres:12 24 | env_file: 25 | - .env 26 | networks: 27 | - webnet 28 | environment: 29 | POSTGRES_PASSWORD: ${DB_PASSWORD} 30 | POSTGRES_USER: ${DB_USERNAME} 31 | POSTGRES_DB: ${DB_DATABASE_NAME} 32 | PG_DATA: /var/lib/postgresql/data 33 | ports: 34 | - ${DB_PORT}:5432 35 | volumes: 36 | - pgdata:/var/lib/postgresql/data 37 | 38 | redis: 39 | image: "redis:alpine" 40 | container_name: redis 41 | restart: always 42 | healthcheck: 43 | test: [ "CMD", "redis-cli", "ping" ] 44 | volumes: 45 | - .:/data 46 | env_file: 47 | - .env 48 | ports: 49 | - ${REDIS_PORT}:6379 50 | 51 | 52 | networks: 53 | webnet: 54 | driver: bridge 55 | volumes: 56 | pgdata: 57 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const getNumberOfDistinctArrayValues = (arr) => { 2 | if (arr.length < 1) return 0; 3 | let uniqueArray = []; 4 | for (const element of arr) { 5 | if (!uniqueArray.includes(element)) { 6 | uniqueArray.push(element); 7 | } 8 | } 9 | return uniqueArray.length; 10 | }; 11 | 12 | console.log(getNumberOfDistinctArrayValues([])); 13 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": ["@nestjs/swagger"], 6 | "assets": [ 7 | { "include": "i18n/**/*", "watchAssets": true }, 8 | { "include": "mail/templates/**/*", "watchAssets": true }, 9 | { "include": "**/*.css", "watchAssets": true } 10 | ] 11 | }, 12 | "plugins": [ 13 | { 14 | "name": "@nestjs/swagger", 15 | "options": { 16 | "dtoFileNameSuffix": [".dto.ts", ".entity.ts"], 17 | "classValidatorShim": true, 18 | "introspectComments": true 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/profile/.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore sample 2 | ################### 3 | 4 | # Ignore all files in this dir... 5 | * 6 | 7 | # ... except for this one. 8 | !.gitignore 9 | !.logo.svg -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | @Get('/health') 6 | health() { 7 | return { 8 | status: 200 9 | }; 10 | } 11 | 12 | @Get('') 13 | index() { 14 | return { 15 | message: 'hello world' 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ThrottlerModule } from '@nestjs/throttler'; 4 | import { APP_FILTER, APP_GUARD, APP_PIPE } from '@nestjs/core'; 5 | import * as path from 'path'; 6 | import * as config from 'config'; 7 | import { ServeStaticModule } from '@nestjs/serve-static'; 8 | import { join } from 'path'; 9 | import { 10 | CookieResolver, 11 | HeaderResolver, 12 | I18nJsonParser, 13 | I18nModule, 14 | QueryResolver 15 | } from 'nestjs-i18n'; 16 | import { WinstonModule } from 'nest-winston'; 17 | 18 | import { AuthModule } from 'src/auth/auth.module'; 19 | import { RolesModule } from 'src/role/roles.module'; 20 | import { PermissionsModule } from 'src/permission/permissions.module'; 21 | import * as ormConfig from 'src/config/ormconfig'; 22 | import * as throttleConfig from 'src/config/throttle-config'; 23 | import { MailModule } from 'src/mail/mail.module'; 24 | import { EmailTemplateModule } from 'src/email-template/email-template.module'; 25 | import { RefreshTokenModule } from 'src/refresh-token/refresh-token.module'; 26 | import { I18nExceptionFilterPipe } from 'src/common/pipes/i18n-exception-filter.pipe'; 27 | import { CustomValidationPipe } from 'src/common/pipes/custom-validation.pipe'; 28 | import { TwofaModule } from 'src/twofa/twofa.module'; 29 | import { CustomThrottlerGuard } from 'src/common/guard/custom-throttle.guard'; 30 | import { DashboardModule } from 'src/dashboard/dashboard.module'; 31 | import { AppController } from 'src/app.controller'; 32 | import winstonConfig from 'src/config/winston'; 33 | 34 | const appConfig = config.get('app'); 35 | 36 | @Module({ 37 | imports: [ 38 | WinstonModule.forRoot(winstonConfig), 39 | ThrottlerModule.forRootAsync({ 40 | useFactory: () => throttleConfig 41 | }), 42 | TypeOrmModule.forRootAsync({ 43 | useFactory: () => ormConfig 44 | }), 45 | I18nModule.forRootAsync({ 46 | useFactory: () => ({ 47 | fallbackLanguage: appConfig.fallbackLanguage, 48 | parserOptions: { 49 | path: path.join(__dirname, '/i18n/'), 50 | watch: true 51 | } 52 | }), 53 | parser: I18nJsonParser, 54 | resolvers: [ 55 | { 56 | use: QueryResolver, 57 | options: ['lang', 'locale', 'l'] 58 | }, 59 | new HeaderResolver(['x-custom-lang']), 60 | new CookieResolver(['lang', 'locale', 'l']) 61 | ] 62 | }), 63 | ServeStaticModule.forRoot({ 64 | rootPath: join(__dirname, '..', 'public'), 65 | exclude: ['/api*'] 66 | }), 67 | AuthModule, 68 | RolesModule, 69 | PermissionsModule, 70 | MailModule, 71 | EmailTemplateModule, 72 | RefreshTokenModule, 73 | TwofaModule, 74 | DashboardModule 75 | ], 76 | providers: [ 77 | { 78 | provide: APP_PIPE, 79 | useClass: CustomValidationPipe 80 | }, 81 | { 82 | provide: APP_GUARD, 83 | useClass: CustomThrottlerGuard 84 | }, 85 | { 86 | provide: APP_FILTER, 87 | useClass: I18nExceptionFilterPipe 88 | } 89 | ], 90 | controllers: [AppController] 91 | }) 92 | export class AppModule {} 93 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import * as Redis from 'ioredis'; 6 | import * as config from 'config'; 7 | 8 | import { AuthController } from 'src/auth/auth.controller'; 9 | import { AuthService } from 'src/auth/auth.service'; 10 | import { UserRepository } from 'src/auth/user.repository'; 11 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 12 | import { MailModule } from 'src/mail/mail.module'; 13 | import { RateLimiterRedis } from 'rate-limiter-flexible'; 14 | import { RefreshTokenModule } from 'src/refresh-token/refresh-token.module'; 15 | import { JwtTwoFactorStrategy } from 'src/common/strategy/jwt-two-factor.strategy'; 16 | import { JwtStrategy } from 'src/common/strategy/jwt.strategy'; 17 | 18 | const throttleConfig = config.get('throttle.login'); 19 | const redisConfig = config.get('queue'); 20 | const jwtConfig = config.get('jwt'); 21 | const LoginThrottleFactory = { 22 | provide: 'LOGIN_THROTTLE', 23 | useFactory: () => { 24 | const redisClient = new Redis({ 25 | enableOfflineQueue: false, 26 | host: process.env.REDIS_HOST || redisConfig.host, 27 | port: process.env.REDIS_PORT || redisConfig.port, 28 | password: process.env.REDIS_PASSWORD || redisConfig.password 29 | }); 30 | 31 | return new RateLimiterRedis({ 32 | storeClient: redisClient, 33 | keyPrefix: throttleConfig.prefix, 34 | points: throttleConfig.limit, 35 | duration: 60 * 60 * 24 * 30, // Store number for 30 days since first fail 36 | blockDuration: throttleConfig.blockDuration 37 | }); 38 | } 39 | }; 40 | 41 | @Module({ 42 | imports: [ 43 | JwtModule.registerAsync({ 44 | useFactory: () => ({ 45 | secret: process.env.JWT_SECRET || jwtConfig.secret, 46 | signOptions: { 47 | expiresIn: process.env.JWT_EXPIRES_IN || jwtConfig.expiresIn 48 | } 49 | }) 50 | }), 51 | PassportModule.register({ 52 | defaultStrategy: 'jwt' 53 | }), 54 | TypeOrmModule.forFeature([UserRepository]), 55 | MailModule, 56 | RefreshTokenModule 57 | ], 58 | controllers: [AuthController], 59 | providers: [ 60 | AuthService, 61 | JwtTwoFactorStrategy, 62 | JwtStrategy, 63 | UniqueValidatorPipe, 64 | LoginThrottleFactory 65 | ], 66 | exports: [ 67 | AuthService, 68 | JwtTwoFactorStrategy, 69 | JwtStrategy, 70 | PassportModule, 71 | JwtModule 72 | ] 73 | }) 74 | export class AuthModule {} 75 | -------------------------------------------------------------------------------- /src/auth/dto/change-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | import { IsEqualTo } from 'src/common/decorators/is-equal-to.decorator'; 4 | 5 | /** 6 | * change password data transfer object 7 | */ 8 | export class ChangePasswordDto { 9 | @IsNotEmpty() 10 | oldPassword: string; 11 | 12 | @IsNotEmpty() 13 | @MinLength(6, { 14 | message: 'minLength-{"ln":6,"count":6}' 15 | }) 16 | @MaxLength(20, { 17 | message: 'maxLength-{"ln":20,"count":20}' 18 | }) 19 | @Matches( 20 | /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{6,20}$/, 21 | { 22 | message: 23 | 'password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character' 24 | } 25 | ) 26 | password: string; 27 | 28 | @IsNotEmpty() 29 | @IsEqualTo('password', { 30 | message: 'isEqualTo-{"field":"password"}' 31 | }) 32 | confirmPassword: string; 33 | } 34 | -------------------------------------------------------------------------------- /src/auth/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsIn, IsNumber } from 'class-validator'; 2 | import { OmitType } from '@nestjs/swagger'; 3 | 4 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 5 | import { RegisterUserDto } from 'src/auth/dto/register-user.dto'; 6 | 7 | const statusEnumArray = [ 8 | UserStatusEnum.ACTIVE, 9 | UserStatusEnum.INACTIVE, 10 | UserStatusEnum.BLOCKED 11 | ]; 12 | 13 | /** 14 | * create user data transform object 15 | */ 16 | export class CreateUserDto extends OmitType(RegisterUserDto, [ 17 | 'password' 18 | ] as const) { 19 | @IsIn(statusEnumArray, { 20 | message: `isIn-{"items":"${statusEnumArray.join(',')}"}` 21 | }) 22 | status: UserStatusEnum; 23 | 24 | @IsNumber() 25 | roleId: number; 26 | } 27 | -------------------------------------------------------------------------------- /src/auth/dto/forget-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | 3 | /** 4 | * forget password data transfer object 5 | */ 6 | export class ForgetPasswordDto { 7 | @IsEmail() 8 | @IsNotEmpty() 9 | email: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/dto/jwt-payload.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JWT payload data transfer object 3 | */ 4 | export class JwtPayloadDto { 5 | subject: string; 6 | isTwoFAAuthenticated?: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/dto/register-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsLowercase, 4 | IsNotEmpty, 5 | IsString, 6 | Matches, 7 | MaxLength, 8 | MinLength, 9 | Validate 10 | } from 'class-validator'; 11 | 12 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 13 | import { UserEntity } from 'src/auth/entity/user.entity'; 14 | 15 | /** 16 | * register user data transform object 17 | */ 18 | export class RegisterUserDto { 19 | @IsNotEmpty() 20 | @IsString() 21 | @IsLowercase() 22 | @Validate(UniqueValidatorPipe, [UserEntity], { 23 | message: 'already taken' 24 | }) 25 | username: string; 26 | 27 | @IsNotEmpty() 28 | @IsEmail() 29 | @IsLowercase() 30 | @Validate(UniqueValidatorPipe, [UserEntity], { 31 | message: 'already taken' 32 | }) 33 | email: string; 34 | 35 | @IsNotEmpty() 36 | @MinLength(6, { 37 | message: 'minLength-{"ln":6,"count":6}' 38 | }) 39 | @MaxLength(20, { 40 | message: 'maxLength-{"ln":20,"count":20}' 41 | }) 42 | @Matches( 43 | /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{6,20}$/, 44 | { 45 | message: 46 | 'password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character' 47 | } 48 | ) 49 | password: string; 50 | 51 | @IsNotEmpty() 52 | @IsString() 53 | name: string; 54 | } 55 | -------------------------------------------------------------------------------- /src/auth/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | import { IsEqualTo } from 'src/common/decorators/is-equal-to.decorator'; 4 | 5 | /** 6 | * reset password data transfer object 7 | */ 8 | export class ResetPasswordDto { 9 | @IsNotEmpty() 10 | token: string; 11 | 12 | @IsNotEmpty() 13 | @MinLength(6, { 14 | message: 'minLength-{"ln":6,"count":6}' 15 | }) 16 | @MaxLength(20, { 17 | message: 'maxLength-{"ln":20,"count":20}' 18 | }) 19 | @Matches( 20 | /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{6,20}$/, 21 | { 22 | message: 23 | 'password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character' 24 | } 25 | ) 26 | password: string; 27 | 28 | @IsNotEmpty() 29 | @IsEqualTo('password') 30 | confirmPassword: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/dto/update-user-profile.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; 2 | import { ValidateIf } from 'class-validator'; 3 | import { UpdateUserDto } from 'src/auth/dto/update-user.dto'; 4 | 5 | /** 6 | * update user profile transfer object 7 | */ 8 | export class UpdateUserProfileDto extends OmitType(UpdateUserDto, [ 9 | 'status', 10 | 'roleId' 11 | ] as const) { 12 | @ApiPropertyOptional() 13 | @ValidateIf((object, value) => value) 14 | avatar: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsIn, IsString, ValidateIf } from 'class-validator'; 2 | import { ApiPropertyOptional } from '@nestjs/swagger'; 3 | 4 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 5 | 6 | const statusEnumArray = [ 7 | UserStatusEnum.ACTIVE, 8 | UserStatusEnum.INACTIVE, 9 | UserStatusEnum.BLOCKED 10 | ]; 11 | /** 12 | * update user data transfer object 13 | */ 14 | export class UpdateUserDto { 15 | @ApiPropertyOptional() 16 | @ValidateIf((object, value) => value) 17 | @IsString() 18 | username: string; 19 | 20 | @ApiPropertyOptional() 21 | @ValidateIf((object, value) => value) 22 | @IsEmail() 23 | email: string; 24 | 25 | @ApiPropertyOptional() 26 | @ValidateIf((object, value) => value) 27 | @IsString() 28 | name: string; 29 | 30 | @ApiPropertyOptional() 31 | @ValidateIf((object, value) => value) 32 | @IsString() 33 | address: string; 34 | 35 | @ApiPropertyOptional() 36 | @ValidateIf((object, value) => value) 37 | @IsString() 38 | contact: string; 39 | 40 | @ApiPropertyOptional() 41 | @ValidateIf((object, value) => value) 42 | @IsIn(statusEnumArray, { 43 | message: `isIn-{"items":"${statusEnumArray.join(',')}"}` 44 | }) 45 | status: UserStatusEnum; 46 | 47 | @ApiPropertyOptional() 48 | @ValidateIf((object, value) => value) 49 | roleId: number; 50 | } 51 | -------------------------------------------------------------------------------- /src/auth/dto/user-login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsLowercase, IsNotEmpty, IsBoolean } from 'class-validator'; 2 | 3 | /** 4 | * user login data transfer object 5 | */ 6 | export class UserLoginDto { 7 | @IsNotEmpty() 8 | @IsLowercase() 9 | username: string; 10 | 11 | @IsNotEmpty() 12 | password: string; 13 | 14 | @IsBoolean() 15 | remember: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/dto/user-search-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | 3 | import { CommonSearchFieldDto } from 'src/common/extra/common-search-field.dto'; 4 | 5 | export class UserSearchFilterDto extends PartialType(CommonSearchFieldDto) {} 6 | -------------------------------------------------------------------------------- /src/auth/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeforeInsert, 3 | BeforeUpdate, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | Index, 8 | JoinColumn, 9 | OneToOne 10 | } from 'typeorm'; 11 | import * as bcrypt from 'bcrypt'; 12 | import { Exclude } from 'class-transformer'; 13 | 14 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 15 | import { CustomBaseEntity } from 'src/common/entity/custom-base.entity'; 16 | import { RoleEntity } from 'src/role/entities/role.entity'; 17 | 18 | /** 19 | * User Entity 20 | */ 21 | @Entity({ 22 | name: 'user' 23 | }) 24 | export class UserEntity extends CustomBaseEntity { 25 | @Index({ 26 | unique: true 27 | }) 28 | @Column() 29 | username: string; 30 | 31 | @Index({ 32 | unique: true 33 | }) 34 | @Column() 35 | email: string; 36 | 37 | @Column() 38 | @Exclude({ 39 | toPlainOnly: true 40 | }) 41 | password: string; 42 | 43 | @Index() 44 | @Column() 45 | name: string; 46 | 47 | @Column() 48 | address: string; 49 | 50 | @Column() 51 | contact: string; 52 | 53 | @Column() 54 | avatar: string; 55 | 56 | @Column() 57 | status: UserStatusEnum; 58 | 59 | @Column() 60 | @Exclude({ 61 | toPlainOnly: true 62 | }) 63 | token: string; 64 | 65 | @CreateDateColumn({ 66 | type: 'timestamp', 67 | default: () => 'CURRENT_TIMESTAMP' 68 | }) 69 | tokenValidityDate: Date; 70 | 71 | @Column() 72 | @Exclude({ 73 | toPlainOnly: true 74 | }) 75 | salt: string; 76 | 77 | @Column({ 78 | nullable: true 79 | }) 80 | @Exclude({ 81 | toPlainOnly: true 82 | }) 83 | twoFASecret?: string; 84 | 85 | @Exclude({ 86 | toPlainOnly: true 87 | }) 88 | @CreateDateColumn({ 89 | type: 'timestamp', 90 | default: () => 'CURRENT_TIMESTAMP' 91 | }) 92 | twoFAThrottleTime?: Date; 93 | 94 | @Column({ 95 | default: false 96 | }) 97 | isTwoFAEnabled: boolean; 98 | 99 | @Exclude({ 100 | toPlainOnly: true 101 | }) 102 | skipHashPassword = false; 103 | 104 | @OneToOne(() => RoleEntity) 105 | @JoinColumn() 106 | role: RoleEntity; 107 | 108 | @Column() 109 | roleId: number; 110 | 111 | @BeforeInsert() 112 | async hashPasswordBeforeInsert() { 113 | if (this.password && !this.skipHashPassword) { 114 | await this.hashPassword(); 115 | } 116 | } 117 | 118 | @BeforeUpdate() 119 | async hashPasswordBeforeUpdate() { 120 | if (this.password && !this.skipHashPassword) { 121 | await this.hashPassword(); 122 | } 123 | } 124 | 125 | async validatePassword(password: string): Promise { 126 | const hash = await bcrypt.hash(password, this.salt); 127 | return hash === this.password; 128 | } 129 | 130 | async hashPassword() { 131 | this.password = await bcrypt.hash(this.password, this.salt); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/auth/pipes/username-unique-validation.pipes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidatorConstraint, 3 | ValidatorConstraintInterface 4 | } from 'class-validator'; 5 | import { Injectable } from '@nestjs/common'; 6 | 7 | import { AuthService } from 'src/auth/auth.service'; 8 | 9 | @ValidatorConstraint({ async: true }) 10 | @Injectable() 11 | export class IsUsernameAlreadyExist implements ValidatorConstraintInterface { 12 | constructor(protected readonly authService: AuthService) {} 13 | 14 | /** 15 | * validate is username unique 16 | * @param text 17 | */ 18 | async validate(text: string) { 19 | const user = await this.authService.findBy('username', text); 20 | return !user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/serializer/user.serializer.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose, Transform, Type } from 'class-transformer'; 2 | import { 3 | ApiHideProperty, 4 | ApiProperty, 5 | ApiPropertyOptional 6 | } from '@nestjs/swagger'; 7 | 8 | import { ModelSerializer } from 'src/common/serializer/model.serializer'; 9 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 10 | import { RoleSerializer } from 'src/role/serializer/role.serializer'; 11 | 12 | export const adminUserGroupsForSerializing: string[] = ['admin']; 13 | export const ownerUserGroupsForSerializing: string[] = ['owner']; 14 | export const defaultUserGroupsForSerializing: string[] = ['timestamps']; 15 | 16 | /** 17 | * user serializer 18 | */ 19 | export class UserSerializer extends ModelSerializer { 20 | @Expose({ 21 | groups: [...ownerUserGroupsForSerializing, ...adminUserGroupsForSerializing] 22 | }) 23 | id: number; 24 | 25 | @ApiProperty() 26 | username: string; 27 | 28 | @ApiProperty() 29 | email: string; 30 | 31 | @ApiProperty() 32 | name: string; 33 | 34 | @ApiProperty() 35 | @Transform(({ value }) => (value !== 'null' ? value : '')) 36 | address: string; 37 | 38 | @ApiProperty() 39 | @Expose({ 40 | groups: ownerUserGroupsForSerializing 41 | }) 42 | isTwoFAEnabled: boolean; 43 | 44 | @ApiProperty() 45 | @Transform(({ value }) => (value !== 'null' ? value : '')) 46 | contact: string; 47 | 48 | @ApiProperty() 49 | @Transform(({ value }) => (value !== 'null' ? value : '')) 50 | avatar: string; 51 | 52 | @ApiPropertyOptional() 53 | @Expose({ 54 | groups: adminUserGroupsForSerializing 55 | }) 56 | status: UserStatusEnum; 57 | 58 | @ApiHideProperty() 59 | @Expose({ 60 | groups: ownerUserGroupsForSerializing 61 | }) 62 | @Type(() => RoleSerializer) 63 | role: RoleSerializer; 64 | 65 | @Exclude({ 66 | toClassOnly: true 67 | }) 68 | roleId: number; 69 | 70 | @Exclude({ 71 | toClassOnly: true 72 | }) 73 | tokenValidityDate: Date; 74 | 75 | @ApiPropertyOptional() 76 | @Expose({ 77 | groups: defaultUserGroupsForSerializing 78 | }) 79 | createdAt: Date; 80 | 81 | @ApiPropertyOptional() 82 | @Expose({ 83 | groups: defaultUserGroupsForSerializing 84 | }) 85 | updatedAt: Date; 86 | } 87 | -------------------------------------------------------------------------------- /src/auth/user-status.enum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * User status enum 3 | */ 4 | export enum UserStatusEnum { 5 | ACTIVE = 'active', 6 | INACTIVE = 'inactive', 7 | BLOCKED = 'blocked' 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, EntityRepository } from 'typeorm'; 2 | import * as bcrypt from 'bcrypt'; 3 | import { classToPlain, plainToClass } from 'class-transformer'; 4 | 5 | import { UserEntity } from 'src/auth/entity/user.entity'; 6 | import { UserLoginDto } from 'src/auth/dto/user-login.dto'; 7 | import { BaseRepository } from 'src/common/repository/base.repository'; 8 | import { UserSerializer } from 'src/auth/serializer/user.serializer'; 9 | import { ResetPasswordDto } from 'src/auth/dto/reset-password.dto'; 10 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 11 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 12 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 13 | 14 | @EntityRepository(UserEntity) 15 | export class UserRepository extends BaseRepository { 16 | /** 17 | * store new user 18 | * @param createUserDto 19 | * @param token 20 | */ 21 | async store( 22 | createUserDto: DeepPartial, 23 | token: string 24 | ): Promise { 25 | if (!createUserDto.status) { 26 | createUserDto.status = UserStatusEnum.INACTIVE; 27 | } 28 | createUserDto.salt = await bcrypt.genSalt(); 29 | createUserDto.token = token; 30 | const user = this.create(createUserDto); 31 | await user.save(); 32 | return this.transform(user); 33 | } 34 | 35 | /** 36 | * login user 37 | * @param userLoginDto 38 | */ 39 | async login( 40 | userLoginDto: UserLoginDto 41 | ): Promise<[user: UserEntity, error: string, code: number]> { 42 | const { username, password } = userLoginDto; 43 | const user = await this.findOne({ 44 | where: [ 45 | { 46 | username: username 47 | }, 48 | { 49 | email: username 50 | } 51 | ] 52 | }); 53 | if (user && (await user.validatePassword(password))) { 54 | if (user.status !== UserStatusEnum.ACTIVE) { 55 | return [ 56 | null, 57 | ExceptionTitleList.UserInactive, 58 | StatusCodesList.UserInactive 59 | ]; 60 | } 61 | return [user, null, null]; 62 | } 63 | return [ 64 | null, 65 | ExceptionTitleList.InvalidCredentials, 66 | StatusCodesList.InvalidCredentials 67 | ]; 68 | } 69 | 70 | /** 71 | * Get user entity for reset password 72 | * @param resetPasswordDto 73 | */ 74 | async getUserForResetPassword( 75 | resetPasswordDto: ResetPasswordDto 76 | ): Promise { 77 | const { token } = resetPasswordDto; 78 | const query = this.createQueryBuilder('user'); 79 | query.where('user.token = :token', { token }); 80 | query.andWhere('user.tokenValidityDate > :date', { 81 | date: new Date() 82 | }); 83 | return query.getOne(); 84 | } 85 | 86 | /** 87 | * transform user 88 | * @param model 89 | * @param transformOption 90 | */ 91 | transform(model: UserEntity, transformOption = {}): UserSerializer { 92 | return plainToClass( 93 | UserSerializer, 94 | classToPlain(model, transformOption), 95 | transformOption 96 | ); 97 | } 98 | 99 | /** 100 | * transform users collection 101 | * @param models 102 | * @param transformOption 103 | */ 104 | transformMany(models: UserEntity[], transformOption = {}): UserSerializer[] { 105 | return models.map((model) => this.transform(model, transformOption)); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/common/constants/exception-title-list.constants.ts: -------------------------------------------------------------------------------- 1 | export const ExceptionTitleList = { 2 | NotFound: 'Not Found', 3 | Forbidden: 'Forbidden', 4 | Unauthorized: 'Unauthorized', 5 | IncorrectOldPassword: 'incorrectOldPassword', 6 | UserInactive: 'userInactive', 7 | BadRequest: 'badRequest', 8 | InvalidCredentials: 'invalidCredentials', 9 | InvalidRefreshToken: 'invalidRefreshToken', 10 | DeleteDefaultError: 'deleteDefaultError', 11 | RefreshTokenExpired: 'refreshTokenExpired', 12 | TooManyTries: 'tooManyTries' 13 | } as const; 14 | -------------------------------------------------------------------------------- /src/common/constants/status-codes-list.constants.ts: -------------------------------------------------------------------------------- 1 | export const StatusCodesList = { 2 | Success: 1001, 3 | ValidationError: 1002, 4 | InternalServerError: 1003, 5 | NotFound: 1004, 6 | UnauthorizedAccess: 1005, 7 | TokenExpired: 1006, 8 | TooManyTries: 1007, 9 | ServiceUnAvailable: 1008, 10 | ThrottleError: 1009, 11 | Forbidden: 1010, 12 | IncorrectOldPassword: 1011, 13 | UserInactive: 1012, 14 | BadRequest: 1013, 15 | InvalidCredentials: 1014, 16 | InvalidRefreshToken: 1015, 17 | UnsupportedFileType: 1016, 18 | OtpRequired: 1017, 19 | DeleteDefaultError: 1018, 20 | RefreshTokenExpired: 1019 21 | } as const; 22 | -------------------------------------------------------------------------------- /src/common/constants/validation-errors-list.constants.ts: -------------------------------------------------------------------------------- 1 | export const ValidationErrorsList = { 2 | IsNotEmpty: 'IsNotEmpty', 3 | MinLength: 'MinLength', 4 | MaxLength: 'MaxLength', 5 | StrongPassword: 'StrongPassword', 6 | IsEqualTo: 'IsEqualTo', 7 | IsString: 'IsString', 8 | UniqueValidate: 'UniqueValidate', 9 | IsEmail: 'IsEmail' 10 | } as const; 11 | -------------------------------------------------------------------------------- /src/common/decorators/get-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | /** 4 | * get logged user custom decorator 5 | */ 6 | export const GetUser = createParamDecorator( 7 | (data: unknown, ctx: ExecutionContext) => { 8 | const request = ctx.switchToHttp().getRequest(); 9 | return request.user; 10 | } 11 | ); 12 | -------------------------------------------------------------------------------- /src/common/decorators/is-equal-to.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions 5 | } from 'class-validator'; 6 | 7 | export function IsEqualTo( 8 | property: string, 9 | validationOptions?: ValidationOptions 10 | ) { 11 | return (object: any, propertyName: string) => { 12 | registerDecorator({ 13 | name: 'isEqualTo', 14 | target: object.constructor, 15 | propertyName, 16 | constraints: [property], 17 | options: validationOptions, 18 | validator: { 19 | validate(value: any, args: ValidationArguments) { 20 | const [relatedPropertyName] = args.constraints; 21 | const relatedValue = (args.object as any)[relatedPropertyName]; 22 | return value === relatedValue; 23 | }, 24 | 25 | defaultMessage(args: ValidationArguments) { 26 | const [relatedPropertyName] = args.constraints; 27 | return `$property must match ${relatedPropertyName} exactly`; 28 | } 29 | } 30 | }); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/common/decorators/sanitize-user.decorators.ts: -------------------------------------------------------------------------------- 1 | import { UserEntity } from 'src/auth/entity/user.entity'; 2 | 3 | /** 4 | * sanitize user fields 5 | * @param userField 6 | * @param strong 7 | * @constructor 8 | */ 9 | export const SanitizeUser = (userField?: string, strong = true) => { 10 | return ( 11 | target: any, 12 | propertyKey: string, 13 | descriptor: TypedPropertyDescriptor 14 | ): TypedPropertyDescriptor => { 15 | const decoratedFn = descriptor.value; 16 | async function newFunction(...args) { 17 | const data: any = await decoratedFn.apply(this, args); 18 | const user: UserEntity = userField ? data[userField] : data; 19 | if (user) { 20 | user.password = null; 21 | delete user.password; 22 | user.salt = null; 23 | delete user.salt; 24 | if (strong) { 25 | user.token = null; 26 | delete user.token; 27 | } 28 | } 29 | return data; 30 | } 31 | return { 32 | value: newFunction 33 | }; 34 | }; 35 | }; 36 | 37 | /** 38 | * sanitize array of users 39 | * @param userField 40 | * @constructor 41 | */ 42 | export const SanitizeUsers = (userField?: string) => { 43 | return ( 44 | target: any, 45 | propertyKey: string, 46 | descriptor: TypedPropertyDescriptor 47 | ): TypedPropertyDescriptor => { 48 | const decoratedFn = descriptor.value; 49 | 50 | async function newFunction(...args) { 51 | const entities: any[] = await decoratedFn.apply(this, args); 52 | return entities.map((entity) => { 53 | const user: UserEntity = userField ? entity[userField] : entity; 54 | if (user) { 55 | user.password = null; 56 | delete user.password; 57 | user.salt = null; 58 | delete user.salt; 59 | user.token = null; 60 | delete user.token; 61 | } 62 | return entity; 63 | }); 64 | } 65 | return { 66 | value: newFunction 67 | }; 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /src/common/entity/custom-base.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | CreateDateColumn, 4 | PrimaryGeneratedColumn, 5 | UpdateDateColumn 6 | } from 'typeorm'; 7 | 8 | /** 9 | * custom base entity 10 | */ 11 | export abstract class CustomBaseEntity extends BaseEntity { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @CreateDateColumn({ 16 | type: 'timestamp', 17 | default: () => 'CURRENT_TIMESTAMP' 18 | }) 19 | createdAt: Date; 20 | 21 | @UpdateDateColumn({ 22 | type: 'timestamp', 23 | default: () => 'CURRENT_TIMESTAMP', 24 | onUpdate: 'CURRENT_TIMESTAMP' 25 | }) 26 | updatedAt: Date; 27 | } 28 | -------------------------------------------------------------------------------- /src/common/exception/exception-filter.ts: -------------------------------------------------------------------------------- 1 | import { I18nService } from 'nestjs-i18n'; 2 | import { 3 | ArgumentsHost, 4 | Catch, 5 | ExceptionFilter, 6 | HttpException, 7 | Inject 8 | } from '@nestjs/common'; 9 | import { Response } from 'express'; 10 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 11 | import { Logger } from 'winston'; 12 | 13 | @Catch(HttpException) 14 | export class CommonExceptionFilter implements ExceptionFilter { 15 | constructor( 16 | @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, 17 | private readonly i18n: I18nService 18 | ) {} 19 | 20 | async catch(exception: HttpException, host: ArgumentsHost) { 21 | const ctx = host.switchToHttp(); 22 | const response = ctx.getResponse(); 23 | const statusCode = exception.getStatus(); 24 | 25 | let message = exception.getResponse() as { 26 | key: string; 27 | args: Record; 28 | }; 29 | 30 | message = await this.i18n.translate(message.key, { 31 | lang: ctx.getRequest().i18nLang, 32 | args: message.args 33 | }); 34 | 35 | this.logger.error('Error: ', { 36 | meta: { 37 | error: message 38 | } 39 | }); 40 | 41 | response.status(statusCode).json({ 42 | statusCode, 43 | message 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/common/extra/common-search-field.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsString, Min, ValidateIf } from 'class-validator'; 3 | import { Transform } from 'class-transformer'; 4 | 5 | export class CommonSearchFieldDto { 6 | @ApiPropertyOptional() 7 | @ValidateIf((object, value) => value) 8 | @IsString() 9 | keywords: string; 10 | 11 | @ApiPropertyOptional() 12 | @ValidateIf((object, value) => value) 13 | @Transform(({ value }) => Number.parseInt(value), { 14 | toClassOnly: true 15 | }) 16 | @Min(1, { 17 | message: 'min-{"ln":1,"count":1}' 18 | }) 19 | limit: number; 20 | 21 | @ApiPropertyOptional() 22 | @ValidateIf((object, value) => value) 23 | @Transform(({ value }) => Number.parseInt(value), { 24 | toClassOnly: true 25 | }) 26 | @Min(1, { 27 | message: 'min-{"ln":1,"count":1}' 28 | }) 29 | page: number; 30 | } 31 | -------------------------------------------------------------------------------- /src/common/guard/custom-throttle.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ThrottlerGuard } from '@nestjs/throttler'; 3 | 4 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 5 | 6 | @Injectable() 7 | export class CustomThrottlerGuard extends ThrottlerGuard { 8 | protected errorMessage = ExceptionTitleList.TooManyTries; 9 | } 10 | -------------------------------------------------------------------------------- /src/common/guard/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { TokenExpiredError } from 'jsonwebtoken'; 4 | 5 | import { ForbiddenException } from 'src/exception/forbidden.exception'; 6 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 7 | import { UnauthorizedException } from 'src/exception/unauthorized.exception'; 8 | 9 | @Injectable() 10 | export class JwtAuthGuard extends AuthGuard('jwt-strategy') { 11 | canActivate(context: ExecutionContext) { 12 | return super.canActivate(context); 13 | } 14 | 15 | handleRequest(err, user, info) { 16 | if (info instanceof TokenExpiredError) { 17 | throw new ForbiddenException( 18 | 'tokenExpired', 19 | StatusCodesList.TokenExpired 20 | ); 21 | } 22 | if (err || !user) { 23 | throw err || new UnauthorizedException(); 24 | } 25 | return user; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/guard/jwt-two-factor.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { TokenExpiredError } from 'jsonwebtoken'; 4 | 5 | import { ForbiddenException } from 'src/exception/forbidden.exception'; 6 | import { UnauthorizedException } from 'src/exception/unauthorized.exception'; 7 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 8 | 9 | @Injectable() 10 | export default class JwtTwoFactorGuard extends AuthGuard('jwt-two-factor') { 11 | canActivate(context: ExecutionContext) { 12 | return super.canActivate(context); 13 | } 14 | 15 | handleRequest(err, user, info) { 16 | if (info instanceof TokenExpiredError) { 17 | throw new ForbiddenException( 18 | 'tokenExpired', 19 | StatusCodesList.TokenExpired 20 | ); 21 | } 22 | if (err || !user) { 23 | throw err || new UnauthorizedException(); 24 | } 25 | return user; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/guard/permission.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { 4 | PermissionConfiguration, 5 | RoutePayloadInterface 6 | } from 'src/config/permission-config'; 7 | import { UserEntity } from 'src/auth/entity/user.entity'; 8 | 9 | @Injectable() 10 | export class PermissionGuard implements CanActivate { 11 | /** 12 | * check if user authorized 13 | * @param context 14 | */ 15 | canActivate( 16 | context: ExecutionContext 17 | ): boolean | Promise | Observable { 18 | const request = context.switchToHttp().getRequest(); 19 | const path = request.route.path; 20 | const method = request.method.toLowerCase(); 21 | const permissionPayload: RoutePayloadInterface = { 22 | path, 23 | method 24 | }; 25 | const permitted = this.checkIfDefaultRoute(permissionPayload); 26 | if (permitted) { 27 | return true; 28 | } 29 | return this.checkIfUserHavePermission(request.user, permissionPayload); 30 | } 31 | 32 | /** 33 | * check if route is default 34 | * @param permissionAgainst 35 | */ 36 | checkIfDefaultRoute(permissionAgainst: RoutePayloadInterface) { 37 | const { path, method } = permissionAgainst; 38 | const defaultRoutes = PermissionConfiguration.defaultRoutes; 39 | return defaultRoutes.some( 40 | (route) => route.path === path && route.method === method 41 | ); 42 | } 43 | 44 | /** 45 | * check if user have necessary permission to view resource 46 | * @param user 47 | * @param permissionAgainst 48 | */ 49 | checkIfUserHavePermission( 50 | user: UserEntity, 51 | permissionAgainst: RoutePayloadInterface 52 | ) { 53 | const { path, method } = permissionAgainst; 54 | if (user && user.role && user.role.permission) { 55 | return user.role.permission.some( 56 | (route) => route.path === path && route.method === method 57 | ); 58 | } 59 | return false; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/common/helper/generate-code.helper.ts: -------------------------------------------------------------------------------- 1 | export class GenerateCodeHelper { 2 | /** 3 | * generate random string code providing length 4 | * @param length 5 | * @param uppercase 6 | * @param lowercase 7 | * @param numerical 8 | */ 9 | generateRandomCode( 10 | length: number, 11 | uppercase = true, 12 | lowercase = true, 13 | numerical = true 14 | ): string { 15 | let result = ''; 16 | const lowerCaseAlphabets = 'abcdefghijklmnopqrstuvwxyz'; 17 | const upperCaseAlphabets = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 18 | const numericalLetters = '0123456789'; 19 | let characters = ''; 20 | if (uppercase) { 21 | characters += upperCaseAlphabets; 22 | } 23 | if (lowercase) { 24 | characters += lowerCaseAlphabets; 25 | } 26 | if (numerical) { 27 | characters += numericalLetters; 28 | } 29 | const charactersLength = characters.length; 30 | for (let i = 0; i < length; i++) { 31 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 32 | } 33 | return result; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/common/helper/multer-options.helper.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { existsSync, mkdirSync } from 'fs'; 3 | import { diskStorage } from 'multer'; 4 | import { extname } from 'path'; 5 | import { v4 as uuid } from 'uuid'; 6 | 7 | import { CustomHttpException } from 'src/exception/custom-http.exception'; 8 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 9 | 10 | export const multerOptionsHelper = ( 11 | destinationPath: string, 12 | maxFileSize: number 13 | ) => ({ 14 | limits: { 15 | fileSize: +maxFileSize 16 | }, 17 | fileFilter: (req: any, file: any, cb: any) => { 18 | if (file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { 19 | cb(null, true); 20 | } else { 21 | cb( 22 | new CustomHttpException( 23 | 'unsupportedFileType', 24 | HttpStatus.BAD_REQUEST, 25 | StatusCodesList.UnsupportedFileType 26 | ), 27 | false 28 | ); 29 | } 30 | }, 31 | storage: diskStorage({ 32 | destination: (req: any, file: any, cb: any) => { 33 | // Create folder if doesn't exist 34 | if (!existsSync(destinationPath)) { 35 | mkdirSync(destinationPath); 36 | } 37 | cb(null, destinationPath); 38 | }, 39 | filename: (req: any, file: any, cb: any) => { 40 | cb(null, `${uuid()}${extname(file.originalname)}`); 41 | } 42 | }) 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/interfaces/common-dto.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * common data transfer object interface 3 | */ 4 | export interface CommonDtoInterface { 5 | [key: string]: any; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/interfaces/common-service.interface.ts: -------------------------------------------------------------------------------- 1 | import { CommonDtoInterface } from 'src/common/interfaces/common-dto.interface'; 2 | import { Pagination } from 'src/paginate'; 3 | 4 | /** 5 | * common service interface 6 | */ 7 | export interface CommonServiceInterface { 8 | create(filter: CommonDtoInterface): Promise; 9 | findAll(filter: CommonDtoInterface): Promise>; 10 | findOne(id: number): Promise; 11 | update(id: number, inputDto: CommonDtoInterface): Promise; 12 | remove(id: number): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/common/interfaces/search-filter.interface.ts: -------------------------------------------------------------------------------- 1 | export interface SearchFilterInterface { 2 | keywords?: string; 3 | limit?: number; 4 | page?: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/interfaces/validation-error.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ValidationErrorInterface { 2 | name: string; 3 | errors: Array; 4 | } 5 | 6 | export interface ValidationPayloadInterface { 7 | property: string; 8 | constraints: Record; 9 | } 10 | -------------------------------------------------------------------------------- /src/common/middleware/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | @Injectable() 5 | export class LoggerMiddleware implements NestMiddleware { 6 | use(req: Request, res: Response, next: NextFunction) { 7 | // const path = req.route.path; 8 | next(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/common/pipes/abstract-unique-validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationArguments, 3 | ValidatorConstraintInterface 4 | } from 'class-validator'; 5 | import { Connection, EntitySchema, FindConditions, ObjectType } from 'typeorm'; 6 | 7 | /** 8 | * unique validation arguments 9 | */ 10 | export interface UniqueValidationArguments extends ValidationArguments { 11 | constraints: [ 12 | ObjectType | EntitySchema | string, 13 | ((validationArguments: ValidationArguments) => FindConditions) | keyof E 14 | ]; 15 | } 16 | 17 | /** 18 | * abstract class to validate unique 19 | */ 20 | export abstract class AbstractUniqueValidator 21 | implements ValidatorConstraintInterface 22 | { 23 | protected constructor(protected readonly connection: Connection) {} 24 | 25 | /** 26 | * validate method to validate provided condition 27 | * @param value 28 | * @param args 29 | */ 30 | public async validate(value: string, args: UniqueValidationArguments) { 31 | const [EntityClass, findCondition = args.property] = args.constraints; 32 | return ( 33 | (await this.connection.getRepository(EntityClass).count({ 34 | where: 35 | typeof findCondition === 'function' 36 | ? findCondition(args) 37 | : { 38 | [findCondition || args.property]: value 39 | } 40 | })) <= 0 41 | ); 42 | } 43 | 44 | /** 45 | * default message 46 | * @param args 47 | */ 48 | public defaultMessage(args: ValidationArguments) { 49 | return `${args.property} '${args.value}' already exists`; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/common/pipes/custom-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | Injectable, 4 | PipeTransform, 5 | UnprocessableEntityException 6 | } from '@nestjs/common'; 7 | import { plainToClass } from 'class-transformer'; 8 | import { validate, ValidationError } from 'class-validator'; 9 | 10 | @Injectable() 11 | export class CustomValidationPipe implements PipeTransform { 12 | async transform(value: any, { metatype }: ArgumentMetadata) { 13 | if (!metatype || !this.toValidate(metatype)) { 14 | return value; 15 | } 16 | const object = plainToClass(metatype, value); 17 | const errors = await validate(object); 18 | if (errors && errors.length > 0) { 19 | const translatedError = await this.transformError(errors); 20 | throw new UnprocessableEntityException(translatedError); 21 | } 22 | return value; 23 | } 24 | 25 | async transformError(errors: ValidationError[]) { 26 | const data = []; 27 | for (const error of errors) { 28 | data.push({ 29 | property: error.property, 30 | constraints: error.constraints 31 | }); 32 | } 33 | return data; 34 | } 35 | 36 | private toValidate(metatype: unknown): boolean { 37 | const types: unknown[] = [String, Boolean, Number, Array, Object]; 38 | return !types.includes(metatype); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/common/pipes/unique-validator.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectConnection } from '@nestjs/typeorm'; 3 | import { ValidatorConstraint } from 'class-validator'; 4 | import { Connection } from 'typeorm'; 5 | 6 | import { AbstractUniqueValidator } from 'src/common/pipes/abstract-unique-validator'; 7 | 8 | /** 9 | * unique validator pipe 10 | */ 11 | @ValidatorConstraint({ 12 | name: 'unique', 13 | async: true 14 | }) 15 | @Injectable() 16 | export class UniqueValidatorPipe extends AbstractUniqueValidator { 17 | constructor( 18 | @InjectConnection() 19 | protected readonly connection: Connection 20 | ) { 21 | super(connection); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/common/serializer/model.serializer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * model serializer 3 | */ 4 | export class ModelSerializer { 5 | id: number; 6 | createdAt: Date; 7 | [key: string]: any; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/strategy/jwt-two-factor.strategy.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import * as config from 'config'; 5 | import { Request } from 'express'; 6 | import { ExtractJwt, Strategy } from 'passport-jwt'; 7 | 8 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 9 | import { CustomHttpException } from 'src/exception/custom-http.exception'; 10 | import { JwtPayloadDto } from 'src/auth/dto/jwt-payload.dto'; 11 | import { UserEntity } from 'src/auth/entity/user.entity'; 12 | import { UserRepository } from 'src/auth/user.repository'; 13 | 14 | @Injectable() 15 | export class JwtTwoFactorStrategy extends PassportStrategy( 16 | Strategy, 17 | 'jwt-two-factor' 18 | ) { 19 | constructor( 20 | @InjectRepository(UserRepository) 21 | private userRepository: UserRepository 22 | ) { 23 | super({ 24 | jwtFromRequest: ExtractJwt.fromExtractors([ 25 | (request: Request) => { 26 | return request?.cookies?.Authentication; 27 | } 28 | ]), 29 | secretOrKey: process.env.JWT_SECRET || config.get('jwt.secret') 30 | }); 31 | } 32 | 33 | async validate(payload: JwtPayloadDto): Promise { 34 | const { isTwoFAAuthenticated, subject } = payload; 35 | const user = await this.userRepository.findOne(Number(subject), { 36 | relations: ['role', 'role.permission'] 37 | }); 38 | if (!user.isTwoFAEnabled) { 39 | return user; 40 | } 41 | if (isTwoFAAuthenticated) { 42 | return user; 43 | } 44 | throw new CustomHttpException( 45 | 'otpRequired', 46 | HttpStatus.FORBIDDEN, 47 | StatusCodesList.OtpRequired 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/common/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import * as config from 'config'; 6 | 7 | import { UserRepository } from 'src/auth/user.repository'; 8 | import { UserEntity } from 'src/auth/entity/user.entity'; 9 | import { JwtPayloadDto } from 'src/auth/dto/jwt-payload.dto'; 10 | import { UnauthorizedException } from 'src/exception/unauthorized.exception'; 11 | 12 | const cookieExtractor = (req) => { 13 | return req?.cookies?.Authentication; 14 | }; 15 | 16 | @Injectable() 17 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-strategy') { 18 | constructor( 19 | @InjectRepository(UserRepository) 20 | private userRepository: UserRepository 21 | ) { 22 | super({ 23 | jwtFromRequest: ExtractJwt.fromExtractors([cookieExtractor]), 24 | secretOrKey: process.env.JWT_SECRET || config.get('jwt.secret') 25 | }); 26 | } 27 | 28 | /** 29 | * Validate if user exists and return user entity 30 | * @param payload 31 | */ 32 | async validate(payload: JwtPayloadDto): Promise { 33 | const { subject } = payload; 34 | const user = await this.userRepository.findOne(Number(subject), { 35 | relations: ['role', 'role.permission'] 36 | }); 37 | if (!user) { 38 | throw new UnauthorizedException(); 39 | } 40 | return user; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/config/email-template.ts: -------------------------------------------------------------------------------- 1 | interface EmailTemplateData { 2 | title: string; 3 | slug: string; 4 | sender: string; 5 | subject: string; 6 | body: string; 7 | isDefault: boolean; 8 | } 9 | 10 | const templates: Array = [ 11 | { 12 | title: 'Activate Account', 13 | slug: 'activate-account', 14 | sender: 'noreply@truthy.com', 15 | subject: 'Activate Account', 16 | isDefault: true, 17 | body: "

Hi {{username}},

A new account has been created using your email . Click below button to activate your account.

{{link}}

If you haven't requested the code please ignore the email.

Thank you!.

" 18 | }, 19 | { 20 | title: 'Two Factor Authentication', 21 | slug: 'two-factor-authentication', 22 | sender: 'noreply@truthy.com', 23 | subject: 'Activate Two Factor Authentication', 24 | isDefault: true, 25 | body: "

Hi {{username}},

This mail is sent because you requested to enable two factor authentication. To configure authentication via TOTP on multiple devices, during setup, scan the QR code using each device at the same time.

QR code OTP

A time-based one-time password (TOTP) application automatically generates an authentication code that changes after a certain period of time. We recommend using cloud-based TOTP apps such as:

If you haven't requested the code please ignore the email.

Thank you!.

" 26 | }, 27 | { 28 | title: 'Reset Password', 29 | slug: 'reset-password', 30 | sender: 'noreply@truthy.com', 31 | subject: 'Reset Password', 32 | isDefault: true, 33 | body: "

Hi {{username}},

You have requested to reset a password. Please use following link to complete the action. Please note this link is only valid for the next hour.

{{link}}

If you haven't requested the code please ignore the email.

Thank you!.

" 34 | }, 35 | { 36 | title: 'New User Set Password', 37 | slug: 'new-user-set-password', 38 | sender: 'noreply@truthy.com', 39 | subject: 'Set Password', 40 | isDefault: true, 41 | body: "

Hi {{username}},

A new account has been created using your email. Please use following link to set password for your account. Please note this link is only valid for the next hour.

{{link}}

If you haven't requested the code please ignore the email.

Thank you!.

" 42 | } 43 | ]; 44 | 45 | export = templates; 46 | -------------------------------------------------------------------------------- /src/config/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionOptions } from 'typeorm'; 2 | import * as config from 'config'; 3 | 4 | const dbConfig = config.get('db'); 5 | const ormConfig: ConnectionOptions = { 6 | type: process.env.DB_TYPE || dbConfig.type, 7 | host: process.env.DB_HOST || dbConfig.host, 8 | port: process.env.DB_PORT || dbConfig.port, 9 | username: process.env.DB_USERNAME || dbConfig.username, 10 | password: process.env.DB_PASSWORD || dbConfig.password, 11 | database: process.env.DB_DATABASE_NAME || dbConfig.database, 12 | migrationsTransactionMode: 'each', 13 | entities: [__dirname + '/../**/*.entity.{js,ts}'], 14 | logging: false, 15 | synchronize: false, 16 | migrationsRun: process.env.NODE_ENV === 'test', 17 | dropSchema: process.env.NODE_ENV === 'test', 18 | migrationsTableName: 'migrations', 19 | migrations: [__dirname + '/../database/migrations/**/*{.ts,.js}'], 20 | cli: { 21 | migrationsDir: 'src/database/migrations' 22 | } 23 | }; 24 | 25 | export = ormConfig; 26 | -------------------------------------------------------------------------------- /src/config/throttle-config.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; 2 | import { ThrottlerModuleOptions } from '@nestjs/throttler'; 3 | import * as config from 'config'; 4 | 5 | const throttleConfigVariables = config.get('throttle.global'); 6 | const redisConfig = config.get('queue'); 7 | const throttleConfig: ThrottlerModuleOptions = { 8 | ttl: process.env.THROTTLE_TTL || throttleConfigVariables.get('ttl'), 9 | limit: process.env.THROTTLE_LIMIT || throttleConfigVariables.get('limit'), 10 | storage: new ThrottlerStorageRedisService({ 11 | host: process.env.REDIS_HOST || redisConfig.host, 12 | port: process.env.REDIS_PORT || redisConfig.port, 13 | password: process.env.REDIS_PASSWORD || redisConfig.password 14 | }) 15 | }; 16 | 17 | export = throttleConfig; 18 | -------------------------------------------------------------------------------- /src/config/winston.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import { utilities as nestWinstonModuleUtilities } from 'nest-winston'; 3 | import { WinstonModuleOptions } from 'nest-winston'; 4 | import * as WinstonCloudWatch from 'winston-cloudwatch'; 5 | import * as config from 'config'; 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | const winstonConfig = config.get('winston'); 9 | 10 | export default { 11 | format: winston.format.colorize(), 12 | exitOnError: false, 13 | transports: isProduction 14 | ? new WinstonCloudWatch({ 15 | name: 'Truthy CMS', 16 | awsOptions: { 17 | credentials: { 18 | accessKeyId: 19 | process.env.AWS_ACCESS_KEY || winstonConfig.awsAccessKeyId, 20 | secretAccessKey: 21 | process.env.AWS_KEY_SECRET || winstonConfig.awsSecretAccessKey 22 | } 23 | }, 24 | logGroupName: 25 | process.env.CLOUDWATCH_GROUP_NAME || winstonConfig.groupName, 26 | logStreamName: 27 | process.env.CLOUDWATCH_STREAM_NAME || winstonConfig.streamName, 28 | awsRegion: process.env.CLOUDWATCH_AWS_REGION || winstonConfig.awsRegion, 29 | messageFormatter: function (item) { 30 | return ( 31 | item.level + ': ' + item.message + ' ' + JSON.stringify(item.meta) 32 | ); 33 | } 34 | }) 35 | : new winston.transports.Console({ 36 | format: winston.format.combine( 37 | winston.format.timestamp(), 38 | winston.format.ms(), 39 | nestWinstonModuleUtilities.format.nestLike('Truthy Logger', { 40 | prettyPrint: true 41 | }) 42 | ) 43 | }) 44 | } as WinstonModuleOptions; 45 | -------------------------------------------------------------------------------- /src/dashboard/dashboard.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | import JwtTwoFactorGuard from 'src/common/guard/jwt-two-factor.guard'; 5 | import { PermissionGuard } from 'src/common/guard/permission.guard'; 6 | import { DashboardService } from 'src/dashboard/dashboard.service'; 7 | import { OsStatsInterface } from 'src/dashboard/interface/os-stats.interface'; 8 | import { UsersStatsInterface } from 'src/dashboard/interface/user-stats.interface'; 9 | import { BrowserStatsInterface } from 'src/dashboard/interface/browser-stats.interface'; 10 | 11 | @ApiTags('dashboard') 12 | @UseGuards(JwtTwoFactorGuard, PermissionGuard) 13 | @Controller('dashboard') 14 | export class DashboardController { 15 | constructor(private readonly dashboardService: DashboardService) {} 16 | 17 | @Get('/users') 18 | userStat(): Promise { 19 | return this.dashboardService.getUserStat(); 20 | } 21 | 22 | @Get('/os') 23 | osStat(): Promise> { 24 | return this.dashboardService.getOsData(); 25 | } 26 | 27 | @Get('/browser') 28 | browserStat(): Promise> { 29 | return this.dashboardService.getBrowserData(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/dashboard/dashboard.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DashboardService } from 'src/dashboard/dashboard.service'; 3 | import { DashboardController } from 'src/dashboard/dashboard.controller'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | 6 | @Module({ 7 | controllers: [DashboardController], 8 | imports: [AuthModule], 9 | providers: [DashboardService] 10 | }) 11 | export class DashboardModule {} 12 | -------------------------------------------------------------------------------- /src/dashboard/dashboard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 4 | import { AuthService } from 'src/auth/auth.service'; 5 | import { UsersStatsInterface } from 'src/dashboard/interface/user-stats.interface'; 6 | import { BrowserStatsInterface } from 'src/dashboard/interface/browser-stats.interface'; 7 | import { OsStatsInterface } from 'src/dashboard/interface/os-stats.interface'; 8 | 9 | @Injectable() 10 | export class DashboardService { 11 | constructor(private readonly authService: AuthService) {} 12 | 13 | async getUserStat(): Promise { 14 | const totalUserPromise = this.authService.countByCondition({}); 15 | const totalActiveUserPromise = this.authService.countByCondition({ 16 | status: UserStatusEnum.ACTIVE 17 | }); 18 | const totalInActiveUserPromise = this.authService.countByCondition({ 19 | status: UserStatusEnum.INACTIVE 20 | }); 21 | const [total, active, inactive] = await Promise.all([ 22 | totalUserPromise, 23 | totalActiveUserPromise, 24 | totalInActiveUserPromise 25 | ]); 26 | return { 27 | total, 28 | active, 29 | inactive 30 | }; 31 | } 32 | 33 | getOsData(): Promise> { 34 | return this.authService.getRefreshTokenGroupedData('os'); 35 | } 36 | 37 | getBrowserData(): Promise> { 38 | return this.authService.getRefreshTokenGroupedData('browser'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/dashboard/dto/create-dashboard.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateDashboardDto {} 2 | -------------------------------------------------------------------------------- /src/dashboard/dto/update-dashboard.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | 3 | import { CreateDashboardDto } from 'src/dashboard/dto/create-dashboard.dto'; 4 | 5 | export class UpdateDashboardDto extends PartialType(CreateDashboardDto) {} 6 | -------------------------------------------------------------------------------- /src/dashboard/entities/dashboard.entity.ts: -------------------------------------------------------------------------------- 1 | export class Dashboard {} 2 | -------------------------------------------------------------------------------- /src/dashboard/interface/browser-stats.interface.ts: -------------------------------------------------------------------------------- 1 | export interface BrowserStatsInterface { 2 | token_browser: string; 3 | count: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/dashboard/interface/os-stats.interface.ts: -------------------------------------------------------------------------------- 1 | export interface OsStatsInterface { 2 | token_os: string; 3 | count: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/dashboard/interface/user-stats.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UsersStatsInterface { 2 | total: number; 3 | active: number; 4 | inactive: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/database/migrations/1614275766942-RoleTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; 2 | 3 | export class RoleTable1614275766942 implements MigrationInterface { 4 | tableName = 'role'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.createTable( 8 | new Table({ 9 | name: this.tableName, 10 | columns: [ 11 | { 12 | name: 'id', 13 | type: 'int', 14 | isPrimary: true, 15 | isGenerated: true, 16 | generationStrategy: 'increment' 17 | }, 18 | { 19 | name: 'name', 20 | type: 'varchar', 21 | isNullable: false, 22 | isUnique: true, 23 | length: '100' 24 | }, 25 | { 26 | name: 'description', 27 | type: 'text', 28 | isNullable: true 29 | }, 30 | { 31 | name: 'createdAt', 32 | type: 'timestamp', 33 | default: 'now()' 34 | }, 35 | { 36 | name: 'updatedAt', 37 | type: 'timestamp', 38 | default: 'now()' 39 | } 40 | ] 41 | }), 42 | false 43 | ); 44 | 45 | await queryRunner.createIndex( 46 | this.tableName, 47 | new TableIndex({ 48 | name: `IDX_ROLE_NAME`, 49 | columnNames: ['name'] 50 | }) 51 | ); 52 | } 53 | 54 | public async down(queryRunner: QueryRunner): Promise { 55 | const table = await queryRunner.getTable(this.tableName); 56 | const index = `IDX_ROLE_NAME`; 57 | const nameIndex = table.indices.find((fk) => fk.name.indexOf(index) !== -1); 58 | if (nameIndex) { 59 | await queryRunner.dropIndex(this.tableName, nameIndex); 60 | } 61 | await queryRunner.dropTable(this.tableName); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/database/migrations/1614275788549-PermissionTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; 2 | 3 | export class PermissionTable1614275788549 implements MigrationInterface { 4 | tableName = 'permission'; 5 | indexFields = ['resource', 'description']; 6 | 7 | public async up(queryRunner: QueryRunner): Promise { 8 | await queryRunner.createTable( 9 | new Table({ 10 | name: this.tableName, 11 | columns: [ 12 | { 13 | name: 'id', 14 | type: 'int', 15 | isPrimary: true, 16 | isGenerated: true, 17 | generationStrategy: 'increment' 18 | }, 19 | { 20 | name: 'resource', 21 | type: 'varchar', 22 | length: '100' 23 | }, 24 | { 25 | name: 'path', 26 | type: 'varchar', 27 | isNullable: false 28 | }, 29 | { 30 | name: 'description', 31 | type: 'text', 32 | isNullable: true, 33 | isUnique: true 34 | }, 35 | { 36 | name: 'method', 37 | type: 'varchar', 38 | default: `'get'`, 39 | length: '20' 40 | }, 41 | { 42 | name: 'isDefault', 43 | type: 'boolean', 44 | default: false 45 | }, 46 | { 47 | name: 'createdAt', 48 | type: 'timestamp', 49 | default: 'now()' 50 | }, 51 | { 52 | name: 'updatedAt', 53 | type: 'timestamp', 54 | default: 'now()' 55 | } 56 | ] 57 | }), 58 | false 59 | ); 60 | 61 | for (const field of this.indexFields) { 62 | await queryRunner.createIndex( 63 | this.tableName, 64 | new TableIndex({ 65 | name: `IDX_PERMISSION_${field.toUpperCase()}`, 66 | columnNames: [field] 67 | }) 68 | ); 69 | } 70 | } 71 | 72 | public async down(queryRunner: QueryRunner): Promise { 73 | const table = await queryRunner.getTable(this.tableName); 74 | for (const field of this.indexFields) { 75 | const index = `IDX_PERMISSION_${field.toUpperCase()}`; 76 | const keyIndex = table.indices.find( 77 | (fk) => fk.name.indexOf(index) !== -1 78 | ); 79 | if (keyIndex) { 80 | await queryRunner.dropIndex(this.tableName, keyIndex); 81 | } 82 | } 83 | await queryRunner.dropTable(this.tableName); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/database/migrations/1614275796207-PermissionRoleTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | Table, 5 | TableColumn, 6 | TableForeignKey 7 | } from 'typeorm'; 8 | 9 | export class PermissionRoleTable1614275796207 implements MigrationInterface { 10 | foreignKeysArray = [ 11 | { 12 | table: 'role', 13 | field: 'roleId', 14 | reference: 'id' 15 | }, 16 | { 17 | table: 'permission', 18 | field: 'permissionId', 19 | reference: 'id' 20 | } 21 | ]; 22 | tableName = 'role_permission'; 23 | 24 | public async up(queryRunner: QueryRunner): Promise { 25 | await queryRunner.createTable( 26 | new Table({ 27 | name: this.tableName, 28 | columns: [ 29 | // { 30 | // name: 'id', 31 | // type: 'int', 32 | // isPrimary: true, 33 | // isGenerated: true, 34 | // generationStrategy: 'increment' 35 | // } 36 | ] 37 | }), 38 | false 39 | ); 40 | for (const foreignKey of this.foreignKeysArray) { 41 | await queryRunner.addColumn( 42 | this.tableName, 43 | new TableColumn({ 44 | name: foreignKey.field, 45 | type: 'int' 46 | }) 47 | ); 48 | 49 | await queryRunner.createForeignKey( 50 | this.tableName, 51 | new TableForeignKey({ 52 | columnNames: [foreignKey.field], 53 | referencedColumnNames: [foreignKey.reference], 54 | referencedTableName: foreignKey.table, 55 | onDelete: 'CASCADE' 56 | }) 57 | ); 58 | } 59 | } 60 | 61 | public async down(queryRunner: QueryRunner): Promise { 62 | const table = await queryRunner.getTable(this.tableName); 63 | for (const key of this.foreignKeysArray) { 64 | const foreignKey = table.foreignKeys.find( 65 | (fk) => fk.columnNames.indexOf(key.field) !== -1 66 | ); 67 | await queryRunner.dropForeignKey(this.tableName, foreignKey); 68 | await queryRunner.dropColumn(this.tableName, key.field); 69 | } 70 | await queryRunner.dropTable(this.tableName); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/database/migrations/1614275816426-UserTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | Table, 5 | TableColumn, 6 | TableForeignKey, 7 | TableIndex 8 | } from 'typeorm'; 9 | 10 | export class UserTable1614275816426 implements MigrationInterface { 11 | indexFields = ['name', 'email', 'username']; 12 | tableName = 'user'; 13 | 14 | public async up(queryRunner: QueryRunner): Promise { 15 | await queryRunner.createTable( 16 | new Table({ 17 | name: this.tableName, 18 | columns: [ 19 | { 20 | name: 'id', 21 | type: 'int', 22 | isPrimary: true, 23 | isGenerated: true, 24 | generationStrategy: 'increment' 25 | }, 26 | { 27 | name: 'username', 28 | type: 'varchar', 29 | isNullable: false, 30 | isUnique: true, 31 | length: '100' 32 | }, 33 | { 34 | name: 'email', 35 | type: 'varchar', 36 | isNullable: false, 37 | isUnique: true, 38 | length: '100' 39 | }, 40 | { 41 | name: 'password', 42 | type: 'varchar', 43 | isNullable: true 44 | }, 45 | { 46 | name: 'name', 47 | type: 'varchar', 48 | isNullable: true 49 | }, 50 | { 51 | name: 'address', 52 | type: 'varchar', 53 | isNullable: true 54 | }, 55 | { 56 | name: 'contact', 57 | type: 'varchar', 58 | isNullable: true 59 | }, 60 | { 61 | name: 'salt', 62 | type: 'varchar', 63 | isNullable: true 64 | }, 65 | { 66 | name: 'token', 67 | type: 'varchar', 68 | isNullable: true 69 | }, 70 | { 71 | name: 'status', 72 | type: 'varchar', 73 | default: `'active'` 74 | }, 75 | { 76 | name: 'createdAt', 77 | type: 'timestamp', 78 | default: 'now()' 79 | }, 80 | { 81 | name: 'updatedAt', 82 | type: 'timestamp', 83 | default: 'now()' 84 | } 85 | ] 86 | }), 87 | false 88 | ); 89 | 90 | for (const field of this.indexFields) { 91 | await queryRunner.createIndex( 92 | this.tableName, 93 | new TableIndex({ 94 | name: `IDX_USER_${field.toUpperCase()}`, 95 | columnNames: [field] 96 | }) 97 | ); 98 | } 99 | 100 | await queryRunner.addColumn( 101 | this.tableName, 102 | new TableColumn({ 103 | name: 'roleId', 104 | type: 'int' 105 | }) 106 | ); 107 | 108 | await queryRunner.createForeignKey( 109 | this.tableName, 110 | new TableForeignKey({ 111 | columnNames: ['roleId'], 112 | referencedColumnNames: ['id'], 113 | referencedTableName: 'role', 114 | onDelete: 'CASCADE' 115 | }) 116 | ); 117 | } 118 | 119 | public async down(queryRunner: QueryRunner): Promise { 120 | const table = await queryRunner.getTable(this.tableName); 121 | 122 | const foreignKey = await table.foreignKeys.find( 123 | (fk) => fk.columnNames.indexOf('roleId') !== -1 124 | ); 125 | await queryRunner.dropForeignKey(this.tableName, foreignKey); 126 | await queryRunner.dropColumn(this.tableName, 'roleId'); 127 | 128 | for (const field of this.indexFields) { 129 | const index = `IDX_USER_${field.toUpperCase()}`; 130 | const keyIndex = await table.indices.find( 131 | (fk) => fk.name.indexOf(index) !== -1 132 | ); 133 | await queryRunner.dropIndex(this.tableName, keyIndex); 134 | } 135 | await queryRunner.dropTable(this.tableName); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/database/migrations/1617559216655-addTokenValidityDateInUserEntity.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class addTokenValidityDateInUserEntity1617559216655 4 | implements MigrationInterface 5 | { 6 | tableName = 'user'; 7 | 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.addColumn( 10 | this.tableName, 11 | new TableColumn({ 12 | name: 'tokenValidityDate', 13 | type: 'timestamp', 14 | default: 'now()' 15 | }) 16 | ); 17 | } 18 | 19 | public async down(queryRunner: QueryRunner): Promise { 20 | await queryRunner.dropColumn( 21 | this.tableName, 22 | new TableColumn({ 23 | name: 'tokenValidityDate', 24 | type: 'timestamp', 25 | default: 'now()' 26 | }) 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/database/migrations/1622305543735-EmailTemplate.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; 2 | 3 | export class EmailTemplate1622305543735 implements MigrationInterface { 4 | tableName = 'email_templates'; 5 | index = 'IDX_EMAIL_TEMPLATES_TITLE'; 6 | 7 | public async up(queryRunner: QueryRunner): Promise { 8 | await queryRunner.createTable( 9 | new Table({ 10 | name: this.tableName, 11 | columns: [ 12 | { 13 | name: 'id', 14 | type: 'int', 15 | isPrimary: true, 16 | isGenerated: true, 17 | generationStrategy: 'increment' 18 | }, 19 | { 20 | name: 'title', 21 | type: 'varchar', 22 | isNullable: false, 23 | isUnique: true, 24 | length: '200' 25 | }, 26 | { 27 | name: 'slug', 28 | type: 'varchar', 29 | isNullable: false, 30 | isUnique: true, 31 | length: '200' 32 | }, 33 | { 34 | name: 'sender', 35 | type: 'varchar', 36 | isNullable: false, 37 | length: '200' 38 | }, 39 | { 40 | name: 'subject', 41 | type: 'text', 42 | isNullable: true 43 | }, 44 | { 45 | name: 'body', 46 | type: 'text', 47 | isNullable: true 48 | }, 49 | { 50 | name: 'isDefault', 51 | type: 'boolean', 52 | default: false 53 | }, 54 | { 55 | name: 'createdAt', 56 | type: 'timestamp', 57 | default: 'now()' 58 | }, 59 | { 60 | name: 'updatedAt', 61 | type: 'timestamp', 62 | default: 'now()' 63 | } 64 | ] 65 | }), 66 | false 67 | ); 68 | 69 | await queryRunner.createIndex( 70 | this.tableName, 71 | new TableIndex({ 72 | name: `${this.index}`, 73 | columnNames: ['title'] 74 | }) 75 | ); 76 | } 77 | 78 | public async down(queryRunner: QueryRunner): Promise { 79 | const table = await queryRunner.getTable(this.tableName); 80 | const nameIndex = table.indices.find( 81 | (ik) => ik.name.indexOf(this.index) !== -1 82 | ); 83 | if (nameIndex) { 84 | await queryRunner.dropIndex(this.tableName, nameIndex); 85 | } 86 | await queryRunner.dropTable(this.tableName); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/database/migrations/1623601947397-CreateRefreshTokenTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | Table, 5 | TableColumn, 6 | TableForeignKey 7 | } from 'typeorm'; 8 | 9 | export class CreateRefreshTokenTable1623601947397 10 | implements MigrationInterface 11 | { 12 | foreignKeysArray = [ 13 | { 14 | table: 'user', 15 | field: 'userId', 16 | reference: 'id' 17 | } 18 | ]; 19 | tableName = 'refresh_token'; 20 | 21 | public async up(queryRunner: QueryRunner): Promise { 22 | await queryRunner.createTable( 23 | new Table({ 24 | name: this.tableName, 25 | columns: [ 26 | { 27 | name: 'id', 28 | type: 'int', 29 | isPrimary: true, 30 | isGenerated: true, 31 | generationStrategy: 'increment' 32 | }, 33 | { 34 | name: 'isRevoked', 35 | type: 'boolean', 36 | default: false 37 | }, 38 | { 39 | name: 'expires', 40 | type: 'timestamp', 41 | default: 'now()' 42 | } 43 | ] 44 | }), 45 | false 46 | ); 47 | 48 | for (const foreignKey of this.foreignKeysArray) { 49 | await queryRunner.addColumn( 50 | this.tableName, 51 | new TableColumn({ 52 | name: foreignKey.field, 53 | type: 'int' 54 | }) 55 | ); 56 | 57 | await queryRunner.createForeignKey( 58 | this.tableName, 59 | new TableForeignKey({ 60 | columnNames: [foreignKey.field], 61 | referencedColumnNames: [foreignKey.reference], 62 | referencedTableName: foreignKey.table, 63 | onDelete: 'CASCADE' 64 | }) 65 | ); 66 | } 67 | } 68 | 69 | public async down(queryRunner: QueryRunner): Promise { 70 | const table = await queryRunner.getTable(this.tableName); 71 | for (const key of this.foreignKeysArray) { 72 | const foreignKey = table.foreignKeys.find( 73 | (fk) => fk.columnNames.indexOf(key.field) !== -1 74 | ); 75 | await queryRunner.dropForeignKey(this.tableName, foreignKey); 76 | await queryRunner.dropColumn(this.tableName, key.field); 77 | } 78 | await queryRunner.dropTable(this.tableName); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/database/migrations/1623777103308-AddUserAgentRefreshTokenTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class AddUserAgentRefreshTokenTable1623777103308 4 | implements MigrationInterface 5 | { 6 | tableName = 'refresh_token'; 7 | columns = [ 8 | new TableColumn({ 9 | name: 'ip', 10 | type: 'varchar', 11 | isNullable: true, 12 | length: '50' 13 | }), 14 | new TableColumn({ 15 | name: 'userAgent', 16 | type: 'text', 17 | isNullable: true 18 | }) 19 | ]; 20 | public async up(queryRunner: QueryRunner): Promise { 21 | await queryRunner.addColumns(this.tableName, this.columns); 22 | } 23 | 24 | public async down(queryRunner: QueryRunner): Promise { 25 | await queryRunner.dropColumns(this.tableName, this.columns); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/database/migrations/1626924978575-AddAvatarColumnUserTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class AddAvatarColumnUserTable1626924978575 4 | implements MigrationInterface 5 | { 6 | tableName = 'user'; 7 | columns = [ 8 | new TableColumn({ 9 | name: 'avatar', 10 | type: 'varchar', 11 | isNullable: true, 12 | length: '200' 13 | }) 14 | ]; 15 | public async up(queryRunner: QueryRunner): Promise { 16 | await queryRunner.addColumns(this.tableName, this.columns); 17 | } 18 | 19 | public async down(queryRunner: QueryRunner): Promise { 20 | await queryRunner.dropColumns(this.tableName, this.columns); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/database/migrations/1627278359782-Add2faColumnsUserTable.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class Add2faColumnsUserTable1627278359782 implements MigrationInterface { 4 | tableName = 'user'; 5 | columns = [ 6 | new TableColumn({ 7 | name: 'twoFASecret', 8 | type: 'varchar', 9 | isNullable: true 10 | }), 11 | new TableColumn({ 12 | name: 'isTwoFAEnabled', 13 | type: 'boolean', 14 | default: false 15 | }) 16 | ]; 17 | public async up(queryRunner: QueryRunner): Promise { 18 | await queryRunner.addColumns(this.tableName, this.columns); 19 | } 20 | 21 | public async down(queryRunner: QueryRunner): Promise { 22 | await queryRunner.dropColumns(this.tableName, this.columns); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/database/migrations/1627736950484-AddTwoSecretGenerateThrottleTime.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export class AddTwoSecretGenerateThrottleTime1627736950484 4 | implements MigrationInterface 5 | { 6 | tableName = 'user'; 7 | columns = [ 8 | new TableColumn({ 9 | name: 'twoFAThrottleTime', 10 | type: 'timestamp', 11 | default: 'now()' 12 | }) 13 | ]; 14 | public async up(queryRunner: QueryRunner): Promise { 15 | await queryRunner.addColumns(this.tableName, this.columns); 16 | } 17 | 18 | public async down(queryRunner: QueryRunner): Promise { 19 | await queryRunner.dropColumns(this.tableName, this.columns); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/database/migrations/1629136129718-AddBrowserAndOsColumnRefreshTokenTable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | TableColumn, 5 | TableIndex 6 | } from 'typeorm'; 7 | 8 | export class AddBrowserAndOsColumnRefreshTokenTable1629136129718 9 | implements MigrationInterface 10 | { 11 | tableName = 'refresh_token'; 12 | indexFields = ['browser', 'os']; 13 | columns = [ 14 | new TableColumn({ 15 | name: 'browser', 16 | type: 'varchar', 17 | isNullable: true, 18 | length: '200' 19 | }), 20 | new TableColumn({ 21 | name: 'os', 22 | type: 'varchar', 23 | isNullable: true, 24 | length: '200' 25 | }) 26 | ]; 27 | public async up(queryRunner: QueryRunner): Promise { 28 | await queryRunner.addColumns(this.tableName, this.columns); 29 | for (const field of this.indexFields) { 30 | await queryRunner.createIndex( 31 | this.tableName, 32 | new TableIndex({ 33 | name: `IDX_REFRESH_TOKEN_${field.toUpperCase()}`, 34 | columnNames: [field] 35 | }) 36 | ); 37 | } 38 | } 39 | 40 | public async down(queryRunner: QueryRunner): Promise { 41 | const table = await queryRunner.getTable(this.tableName); 42 | for (const field of this.indexFields) { 43 | const index = `IDX_REFRESH_TOKEN_${field.toUpperCase()}`; 44 | const keyIndex = await table.indices.find( 45 | (fk) => fk.name.indexOf(index) !== -1 46 | ); 47 | await queryRunner.dropIndex(this.tableName, keyIndex); 48 | } 49 | await queryRunner.dropColumns(this.tableName, this.columns); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/database/seeds/create-email-template.seed.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'typeorm-seeding'; 2 | import { Connection } from 'typeorm'; 3 | 4 | import * as templates from 'src/config/email-template'; 5 | import { EmailTemplateEntity } from 'src/email-template/entities/email-template.entity'; 6 | 7 | export default class CreateEmailTemplateSeed { 8 | public async run(factory: Factory, connection: Connection): Promise { 9 | await connection 10 | .createQueryBuilder() 11 | .insert() 12 | .into(EmailTemplateEntity) 13 | .values(templates) 14 | .orIgnore() 15 | .execute(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/database/seeds/create-permission.seed.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'typeorm-seeding'; 2 | import { Connection } from 'typeorm'; 3 | 4 | import { 5 | ModulesPayloadInterface, 6 | PermissionConfiguration, 7 | PermissionPayload, 8 | RoutePayloadInterface, 9 | SubModulePayloadInterface 10 | } from 'src/config/permission-config'; 11 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 12 | 13 | export default class CreatePermissionSeed { 14 | permissions: RoutePayloadInterface[] = []; 15 | 16 | public async run(factory: Factory, connection: Connection): Promise { 17 | const modules = PermissionConfiguration.modules; 18 | for (const moduleData of modules) { 19 | let resource = moduleData.resource; 20 | this.assignResourceAndConcatPermission(moduleData, resource); 21 | 22 | if (moduleData.hasSubmodules) { 23 | for (const submodule of moduleData.submodules) { 24 | resource = submodule.resource || resource; 25 | this.assignResourceAndConcatPermission(submodule, resource); 26 | } 27 | } 28 | } 29 | 30 | if (this.permissions && this.permissions.length > 0) { 31 | await connection 32 | .createQueryBuilder() 33 | .insert() 34 | .into(PermissionEntity) 35 | .values(this.permissions) 36 | .orIgnore() 37 | .execute(); 38 | } 39 | } 40 | 41 | assignResourceAndConcatPermission( 42 | modules: ModulesPayloadInterface | SubModulePayloadInterface, 43 | resource: string, 44 | isDefault?: false 45 | ) { 46 | if (modules.permissions) { 47 | for (const permission of modules.permissions) { 48 | this.concatPermissions(permission, resource, isDefault); 49 | } 50 | } 51 | } 52 | 53 | concatPermissions( 54 | permission: PermissionPayload, 55 | resource: string, 56 | isDefault: boolean 57 | ) { 58 | const description = permission.name; 59 | for (const data of permission.route) { 60 | data.resource = data.resource || resource; 61 | data.description = data.description || description; 62 | data.isDefault = isDefault; 63 | } 64 | this.permissions = this.permissions.concat(permission.route); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/database/seeds/create-role.seed.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'typeorm-seeding'; 2 | import { Connection } from 'typeorm'; 3 | 4 | import { RoleEntity } from 'src/role/entities/role.entity'; 5 | import { PermissionConfiguration } from 'src/config/permission-config'; 6 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 7 | 8 | export default class CreateRoleSeed { 9 | public async run(factory: Factory, connection: Connection): Promise { 10 | const roles = PermissionConfiguration.roles; 11 | await connection 12 | .createQueryBuilder() 13 | .insert() 14 | .into(RoleEntity) 15 | .values(roles) 16 | .orIgnore() 17 | .execute(); 18 | 19 | // Assign all permission to superUser 20 | const role = await connection 21 | .getRepository(RoleEntity) 22 | .createQueryBuilder('role') 23 | .where('role.name = :name', { 24 | name: 'superuser' 25 | }) 26 | .getOne(); 27 | 28 | if (role) { 29 | role.permission = await connection 30 | .getRepository(PermissionEntity) 31 | .createQueryBuilder('permission') 32 | .getMany(); 33 | await role.save(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/database/seeds/create-user.seed.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from 'typeorm-seeding'; 2 | import { Connection } from 'typeorm'; 3 | 4 | import { UserEntity } from 'src/auth/entity/user.entity'; 5 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 6 | import { RoleEntity } from 'src/role/entities/role.entity'; 7 | 8 | export default class CreateUserSeed { 9 | public async run(factory: Factory, connection: Connection): Promise { 10 | const role = await connection 11 | .getRepository(RoleEntity) 12 | .createQueryBuilder('role') 13 | .where('role.name = :name', { 14 | name: 'superuser' 15 | }) 16 | .getOne(); 17 | 18 | if (!role) { 19 | return; 20 | } 21 | await connection 22 | .createQueryBuilder() 23 | .insert() 24 | .into(UserEntity) 25 | .values([ 26 | { 27 | username: 'admin', 28 | email: 'admin@truthy.com', 29 | password: 30 | '$2b$10$O9BWip02GuE14bDPfBomQebCjwKQyuUfkulhvBB1UoizOeKxGG8Fu', // Truthy@123 31 | salt: '$2b$10$O9BWip02GuE14bDPfBomQe', 32 | name: 'truthy', 33 | status: UserStatusEnum.ACTIVE, 34 | roleId: role.id 35 | } 36 | ]) 37 | .orIgnore() 38 | .execute(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/email-template/dto/create-email-template.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsBoolean, 3 | IsEmail, 4 | IsNotEmpty, 5 | IsOptional, 6 | IsString, 7 | MaxLength, 8 | MinLength, 9 | Validate 10 | } from 'class-validator'; 11 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 12 | import { EmailTemplateEntity } from 'src/email-template/entities/email-template.entity'; 13 | 14 | export class CreateEmailTemplateDto { 15 | @IsNotEmpty() 16 | @IsString() 17 | @MaxLength(100, { 18 | message: 'maxLength-{"ln":100,"count":100}' 19 | }) 20 | @Validate(UniqueValidatorPipe, [EmailTemplateEntity], { 21 | message: 'already taken' 22 | }) 23 | title: string; 24 | 25 | @IsNotEmpty() 26 | @IsString() 27 | @IsEmail() 28 | sender: string; 29 | 30 | @IsNotEmpty() 31 | @IsString() 32 | subject: string; 33 | 34 | @IsNotEmpty() 35 | @IsString() 36 | @MinLength(50, { 37 | message: 'minLength-{"ln":50,"count":50}' 38 | }) 39 | body: string; 40 | 41 | @IsOptional() 42 | @IsBoolean() 43 | isDefault: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /src/email-template/dto/email-templates-search-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | 3 | import { CommonSearchFieldDto } from 'src/common/extra/common-search-field.dto'; 4 | 5 | export class EmailTemplatesSearchFilterDto extends PartialType( 6 | CommonSearchFieldDto 7 | ) {} 8 | -------------------------------------------------------------------------------- /src/email-template/dto/update-email-template.dto.ts: -------------------------------------------------------------------------------- 1 | import { CreateEmailTemplateDto } from 'src/email-template/dto/create-email-template.dto'; 2 | import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; 3 | import { Optional } from '@nestjs/common'; 4 | import { IsString } from 'class-validator'; 5 | 6 | export class UpdateEmailTemplateDto extends PartialType( 7 | CreateEmailTemplateDto 8 | ) { 9 | @ApiPropertyOptional() 10 | @Optional() 11 | @IsString() 12 | title: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/email-template/email-template.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Put, 11 | Query, 12 | UseGuards 13 | } from '@nestjs/common'; 14 | import { ApiTags } from '@nestjs/swagger'; 15 | 16 | import { EmailTemplateService } from 'src/email-template/email-template.service'; 17 | import { CreateEmailTemplateDto } from 'src/email-template/dto/create-email-template.dto'; 18 | import { UpdateEmailTemplateDto } from 'src/email-template/dto/update-email-template.dto'; 19 | import { PermissionGuard } from 'src/common/guard/permission.guard'; 20 | import { Pagination } from 'src/paginate'; 21 | import { EmailTemplate } from 'src/email-template/serializer/email-template.serializer'; 22 | import { EmailTemplatesSearchFilterDto } from 'src/email-template/dto/email-templates-search-filter.dto'; 23 | import JwtTwoFactorGuard from 'src/common/guard/jwt-two-factor.guard'; 24 | 25 | @ApiTags('email-templates') 26 | @UseGuards(JwtTwoFactorGuard, PermissionGuard) 27 | @Controller('email-templates') 28 | export class EmailTemplateController { 29 | constructor(private readonly emailTemplateService: EmailTemplateService) {} 30 | 31 | @Post() 32 | create( 33 | @Body() 34 | createEmailTemplateDto: CreateEmailTemplateDto 35 | ): Promise { 36 | return this.emailTemplateService.create(createEmailTemplateDto); 37 | } 38 | 39 | @Get() 40 | findAll( 41 | @Query() 42 | filter: EmailTemplatesSearchFilterDto 43 | ): Promise> { 44 | return this.emailTemplateService.findAll(filter); 45 | } 46 | 47 | @Get(':id') 48 | findOne( 49 | @Param('id') 50 | id: string 51 | ): Promise { 52 | return this.emailTemplateService.findOne(+id); 53 | } 54 | 55 | @Put(':id') 56 | update( 57 | @Param('id') 58 | id: string, 59 | @Body() 60 | updateEmailTemplateDto: UpdateEmailTemplateDto 61 | ): Promise { 62 | return this.emailTemplateService.update(+id, updateEmailTemplateDto); 63 | } 64 | 65 | @Delete(':id') 66 | @HttpCode(HttpStatus.NO_CONTENT) 67 | remove( 68 | @Param('id') 69 | id: string 70 | ): Promise { 71 | return this.emailTemplateService.remove(+id); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/email-template/email-template.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { EmailTemplateService } from 'src/email-template/email-template.service'; 5 | import { EmailTemplateController } from 'src/email-template/email-template.controller'; 6 | import { AuthModule } from 'src/auth/auth.module'; 7 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 8 | import { EmailTemplateRepository } from 'src/email-template/email-template.repository'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([EmailTemplateRepository]), 13 | forwardRef(() => AuthModule) 14 | ], 15 | exports: [EmailTemplateService], 16 | controllers: [EmailTemplateController], 17 | providers: [EmailTemplateService, UniqueValidatorPipe] 18 | }) 19 | export class EmailTemplateModule {} 20 | -------------------------------------------------------------------------------- /src/email-template/email-template.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from 'typeorm'; 2 | import { classToPlain, plainToClass } from 'class-transformer'; 3 | 4 | import { BaseRepository } from 'src/common/repository/base.repository'; 5 | import { EmailTemplateEntity } from 'src/email-template/entities/email-template.entity'; 6 | import { EmailTemplate } from 'src/email-template/serializer/email-template.serializer'; 7 | 8 | @EntityRepository(EmailTemplateEntity) 9 | export class EmailTemplateRepository extends BaseRepository< 10 | EmailTemplateEntity, 11 | EmailTemplate 12 | > { 13 | transform(model: EmailTemplateEntity, transformOption = {}): EmailTemplate { 14 | return plainToClass( 15 | EmailTemplate, 16 | classToPlain(model, transformOption), 17 | transformOption 18 | ); 19 | } 20 | 21 | transformMany( 22 | models: EmailTemplateEntity[], 23 | transformOption = {} 24 | ): EmailTemplate[] { 25 | return models.map((model) => this.transform(model, transformOption)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/email-template/entities/email-template.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | 3 | import { CustomBaseEntity } from 'src/common/entity/custom-base.entity'; 4 | 5 | @Entity({ 6 | name: 'email_templates' 7 | }) 8 | export class EmailTemplateEntity extends CustomBaseEntity { 9 | @Column() 10 | @Index({ 11 | unique: true 12 | }) 13 | title: string; 14 | 15 | @Column() 16 | slug: string; 17 | 18 | @Column() 19 | sender: string; 20 | 21 | @Column() 22 | subject: string; 23 | 24 | @Column() 25 | body: string; 26 | 27 | @Column() 28 | isDefault: boolean; 29 | } 30 | -------------------------------------------------------------------------------- /src/email-template/serializer/email-template.serializer.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | 3 | import { ModelSerializer } from 'src/common/serializer/model.serializer'; 4 | 5 | export class EmailTemplate extends ModelSerializer { 6 | id: number; 7 | 8 | @ApiProperty() 9 | title: string; 10 | 11 | @ApiProperty() 12 | slug: string; 13 | 14 | @ApiProperty() 15 | sender: string; 16 | 17 | @ApiProperty() 18 | subject: string; 19 | 20 | @ApiProperty() 21 | body: string; 22 | 23 | @ApiProperty() 24 | isDefault: boolean; 25 | 26 | @ApiPropertyOptional() 27 | createdAt: Date; 28 | 29 | @ApiPropertyOptional() 30 | updatedAt: Date; 31 | } 32 | -------------------------------------------------------------------------------- /src/exception/custom-http.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 3 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 4 | 5 | export class CustomHttpException extends HttpException { 6 | constructor(message?: string, statusCode?: number, code?: number) { 7 | super( 8 | { 9 | message: message || ExceptionTitleList.BadRequest, 10 | code: code || StatusCodesList.BadRequest, 11 | statusCode: statusCode || HttpStatus.BAD_REQUEST, 12 | error: true 13 | }, 14 | statusCode || HttpStatus.BAD_REQUEST 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/exception/forbidden.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 3 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 4 | 5 | export class ForbiddenException extends HttpException { 6 | constructor(message?: string, code?: number) { 7 | super( 8 | { 9 | message: message || ExceptionTitleList.Forbidden, 10 | code: code || StatusCodesList.Forbidden, 11 | statusCode: HttpStatus.FORBIDDEN, 12 | error: true 13 | }, 14 | HttpStatus.FORBIDDEN 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/exception/not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 4 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 5 | 6 | export class NotFoundException extends HttpException { 7 | constructor(message?: string, code?: number) { 8 | super( 9 | { 10 | message: message || ExceptionTitleList.NotFound, 11 | code: code || StatusCodesList.NotFound, 12 | statusCode: HttpStatus.NOT_FOUND, 13 | error: true 14 | }, 15 | HttpStatus.NOT_FOUND 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/exception/unauthorized.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 3 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 4 | 5 | export class UnauthorizedException extends HttpException { 6 | constructor(message?: string, code?: number) { 7 | super( 8 | { 9 | message: message || ExceptionTitleList.Unauthorized, 10 | code: code || StatusCodesList.UnauthorizedAccess, 11 | statusCode: HttpStatus.UNAUTHORIZED, 12 | error: true 13 | }, 14 | HttpStatus.UNAUTHORIZED 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/i18n/en/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": "permissions", 3 | "method": "method", 4 | "path": "path", 5 | "description": "description", 6 | "resource": "resource", 7 | "token": "token", 8 | "page": "page", 9 | "limit": "limit", 10 | "keyword": "keyword", 11 | "sender": "sender", 12 | "subject": "subject", 13 | "body": "body", 14 | "isDefault": "default", 15 | "title": "title", 16 | "password": "password", 17 | "oldPassword": "old password", 18 | "username": "username", 19 | "confirmPassword": "confirm password", 20 | "status": "status", 21 | "email": "email", 22 | "name": "name", 23 | "roleId": "role" 24 | } 25 | -------------------------------------------------------------------------------- /src/i18n/en/exception.json: -------------------------------------------------------------------------------- 1 | { 2 | "sec": { 3 | "one": "second", 4 | "other": "seconds", 5 | "zero": "second" 6 | }, 7 | "Unauthorized": "Unauthorized", 8 | "internalError": "Internal Server Error!", 9 | "otpRequired": "OTP required", 10 | "invalidOTP": "Invalid OTP", 11 | "inactiveUser": "You account has not been activated yet!", 12 | "invalidCredentials": "Credential didn't match!", 13 | "Forbidden": "Access to the requested resource is forbidden!", 14 | "userInactive": "User is inactive please contact your administrator for further help!", 15 | "Not Found": "Data not found!", 16 | "tokenExpired": "Your session has expired!", 17 | "unsupportedFileType": "Uploaded file type is not supported!", 18 | "incorrectOldPassword": "Incorrect old password!", 19 | "badRequest": "Bad Request, There was some error please try again later!", 20 | "tooManyRequest": "Too many tries, retry after {second} $t(exception.sec, {{\"count\": {second} }})!", 21 | "invalidRefreshToken": "Invalid refresh token!", 22 | "deleteDefaultError": "Cannot delete default item!", 23 | "refreshTokenExpired": "Refresh token is expired!", 24 | "tooManyTries": "Too many tries!" 25 | } 26 | -------------------------------------------------------------------------------- /src/i18n/en/validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "character": { 3 | "one": "character", 4 | "other": "characters", 5 | "zero": "character" 6 | }, 7 | "Unauthorized": "Invalid email/password", 8 | "isNotEmpty": "$t(app.{property}) should not be empty", 9 | "maxLength": "$t(app.{property}) can only have maximum length of {ln} $t(validation.character, {{\"count\": {count} }})", 10 | "minLength": "$t(app.{property}) must have minimum length of {ln} $t(validation.character, {{\"count\": {count} }})", 11 | "max": "$t(app.{property}) can be less than or equal to {ln}", 12 | "min": "$t(app.{property}) must have greater than or equal to {ln}", 13 | "password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character": "password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character", 14 | "isIn": "$t(app.{property}) must be one of {items}", 15 | "isEnum": "$t(app.{property}) must be one of {items}", 16 | "isNumberString": "$t(app.{property}) must be number", 17 | "isNumber": "$t(app.{property}) must be number", 18 | "isEqualTo": "$t(app.{property}) must be same as $t(app.{field})", 19 | "isLowercase": "$t(app.{property}) should be in lowercase", 20 | "isEmail": "$t(app.{property}) must be valid email", 21 | "isString": "$t(app.{property}) must be string", 22 | "isBoolean": "field must be selected between true or false", 23 | "already taken": "$t(app.{property}) is already taken", 24 | "unique": "$t(app.{property}) is already taken" 25 | } 26 | -------------------------------------------------------------------------------- /src/i18n/ne/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": "अनुमतिहरू", 3 | "method": "विधि", 4 | "path": "लिंक", 5 | "description": "वर्णन", 6 | "resource": "स्रोत नाम", 7 | "page": "पृष्ठ", 8 | "limit": "सीमा", 9 | "keyword": "कुञ्जी शब्द", 10 | "title": "शीर्षक", 11 | "sender": "प्रेषक", 12 | "subject": "विषय", 13 | "token": "टोकन", 14 | "body": "सामग्री", 15 | "isDefault": "पूर्वनिर्धारित", 16 | "password": "पासवर्ड", 17 | "username": "प्रयोगकर्ता नाम", 18 | "oldPassword": "पुरानो पासवर्ड", 19 | "confirmPassword": "सुनिश्चित गरिएको पासवर्ड", 20 | "status": "स्थिति", 21 | "email": "ईमेल", 22 | "name": "नाम", 23 | "roleId": "भूमिका" 24 | } 25 | -------------------------------------------------------------------------------- /src/i18n/ne/exception.json: -------------------------------------------------------------------------------- 1 | { 2 | "Unauthorized": "अनधिकृत पहुँच अस्वीकृत!", 3 | "internalError": "आन्तरिक सर्वर त्रुटि!", 4 | "inactiveUser": "तपाइँको खाता अझै सक्रिय गरिएको छैन।", 5 | "Not Found": "डाटा भेटिएन!", 6 | "tokenExpired": "तपाईको सत्रको समयावधि सकियो!", 7 | "otpRequired": "OTP आवश्यक छ", 8 | "invalidOTP": "अवैध OTP", 9 | "invalidCredentials": "अमान्य ईमेल/पासवर्ड विवरण", 10 | "Forbidden": "अनुरोध गरिएको संसाधनमा पहुँच निषेध छ!", 11 | "userInactive": "प्रयोगकर्ता निष्क्रिय छ कृपया थप मद्दत को लागी तपाइँको प्रशासक लाई सम्पर्क गर्नुहोस्!", 12 | "incorrectOldPassword": "पुरानो पासवर्ड गलत छ!", 13 | "unsupportedFileType": "अपलोड गरिएको फाइल प्रकार समर्थित छैन!", 14 | "badRequest": "खराब अनुरोध, त्यहाँ केहि त्रुटि थियो कृपया पछि फेरि प्रयास गर्नुहोस्!", 15 | "tooManyRequest": "धेरै प्रयत्नहरू, {second} सेकेन्ड पछि पुनःप्रयास गर्नुहोस्!", 16 | "invalidRefreshToken": "अवैध टोकन!", 17 | "deleteDefaultError": "पूर्वनिर्धारित आइटम मेटाउन मिल्दैन!", 18 | "refreshTokenExpired": "रिफ्रेस टोकनको म्याद सकिएको छ!", 19 | "tooManyTries": "धेरै अनुरोध निषेध!" 20 | } 21 | -------------------------------------------------------------------------------- /src/i18n/ne/validation.json: -------------------------------------------------------------------------------- 1 | { 2 | "character": { 3 | "one": "character", 4 | "other": "characters", 5 | "zero": "character" 6 | }, 7 | "Unauthorized": "अवैध ईमेल/पासवर्ड", 8 | "Not Found": "डाटा भेटिएन", 9 | "isNotEmpty": "$t(app.{property}) खाली हुनु हुँदैन", 10 | "maxLength": "$t(app.{property})को अधिकतम शब्द गणना {ln} मात्र हुनुपर्छ", 11 | "minLength": "$t(app.{property})क न्यूनतम शब्द गणना {ln} हुनुपर्छ", 12 | "max": "$t(app.{property}) {ln} भन्दा कम वा बराबर हुन सक्छ", 13 | "min": "$t(app.{property}) {ln} भन्दा ठूलो वा बराबर हुनै पर्छ", 14 | "password should contain at least one lowercase letter, one uppercase letter, one numeric digit, and one special character": "पासवर्ड कम्तिमा एउटा सानो अक्षर, एक ठूलो अक्षर, एक संख्यात्मक अंक, र एक विशेष वर्ण हुनु पर्छ", 15 | "isEqualTo": "$t(app.{property}) $t(app.{field}) जस्तै हुनु पर्छ", 16 | "isLowercase": "$t(app.{property}) लोअरकेसमा हुनुपर्छ", 17 | "isIn": "$t(app.{property}) {items} मध्ये एक हुनुपर्दछ", 18 | "isEnum": "$t(app.{property}) {items} मध्ये एक हुनुपर्दछ", 19 | "isNumberString": "$t(app.{property}) नम्बर हुनुपर्दछ", 20 | "isNumber": "$t(app.{property}) नम्बर हुनुपर्दछ", 21 | "isEmail": "क्षेत्र मान्य ईमेल हुनुपर्दछ", 22 | "isString": "क्षेत्र शब्दहरू हुनुपर्दछ", 23 | "isBoolean": "क्षेत्र सही वा गलत बीचमा चयन गरिएको हुनुपर्दछ", 24 | "already taken": "$t(app.{property}) पहिले नै अर्को प्रयोगकर्ता द्वारा प्रयोग गरीएको छ", 25 | "unique": "$t(app.{property}) पहिले नै अर्को प्रयोगकर्ता द्वारा प्रयोग गरीएको छ" 26 | } 27 | -------------------------------------------------------------------------------- /src/mail/interface/mail-job.interface.ts: -------------------------------------------------------------------------------- 1 | export interface MailJobInterface { 2 | to: string; 3 | slug: string; 4 | subject: string; 5 | context: any; 6 | attachments?: any; 7 | } 8 | -------------------------------------------------------------------------------- /src/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { BullModule } from '@nestjs/bull'; 3 | import { MailerModule } from '@nestjs-modules/mailer'; 4 | import { PugAdapter } from '@nestjs-modules/mailer/dist/adapters/pug.adapter'; 5 | import * as config from 'config'; 6 | 7 | import { MailService } from 'src/mail/mail.service'; 8 | import { MailProcessor } from 'src/mail/mail.processor'; 9 | import { EmailTemplateModule } from 'src/email-template/email-template.module'; 10 | 11 | const mailConfig = config.get('mail'); 12 | const queueConfig = config.get('queue'); 13 | 14 | @Module({ 15 | imports: [ 16 | EmailTemplateModule, 17 | BullModule.registerQueueAsync({ 18 | name: config.get('mail.queueName'), 19 | useFactory: () => ({ 20 | redis: { 21 | host: process.env.REDIS_HOST || queueConfig.host, 22 | port: process.env.REDIS_PORT || queueConfig.port, 23 | password: process.env.REDIS_PASSWORD || queueConfig.password, 24 | retryStrategy(times) { 25 | return Math.min(times * 50, 2000); 26 | } 27 | } 28 | }) 29 | }), 30 | MailerModule.forRootAsync({ 31 | useFactory: () => ({ 32 | transport: { 33 | host: process.env.MAIL_HOST || mailConfig.host, 34 | port: process.env.MAIL_PORT || mailConfig.port, 35 | secure: mailConfig.secure, 36 | ignoreTLS: mailConfig.ignoreTLS, 37 | auth: { 38 | user: process.env.MAIL_USER || mailConfig.user, 39 | pass: process.env.MAIL_PASS || mailConfig.pass 40 | } 41 | }, 42 | defaults: { 43 | from: `"${process.env.MAIL_FROM || mailConfig.from}" <${ 44 | process.env.MAIL_FROM || mailConfig.fromMail 45 | }>` 46 | }, 47 | preview: mailConfig.preview, 48 | template: { 49 | dir: __dirname + '/templates/email/layouts/', 50 | adapter: new PugAdapter(), 51 | options: { 52 | strict: true 53 | } 54 | } 55 | }) 56 | }) 57 | ], 58 | controllers: [], 59 | providers: [MailService, MailProcessor], 60 | exports: [MailService] 61 | }) 62 | export class MailModule {} 63 | -------------------------------------------------------------------------------- /src/mail/mail.processor.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import * as config from 'config'; 3 | import { MailerService } from '@nestjs-modules/mailer'; 4 | import { 5 | OnQueueActive, 6 | OnQueueCompleted, 7 | OnQueueFailed, 8 | Process, 9 | Processor 10 | } from '@nestjs/bull'; 11 | import { Job } from 'bull'; 12 | 13 | import { MailJobInterface } from 'src/mail/interface/mail-job.interface'; 14 | 15 | @Processor(config.get('mail.queueName')) 16 | export class MailProcessor { 17 | private readonly logger = new Logger(this.constructor.name); 18 | 19 | constructor(private readonly mailerService: MailerService) {} 20 | 21 | @OnQueueActive() 22 | onActive(job: Job) { 23 | this.logger.debug( 24 | `Processing job ${job.id} of type ${job.name}. Data: ${JSON.stringify( 25 | job.data 26 | )}` 27 | ); 28 | } 29 | 30 | @OnQueueCompleted() 31 | onComplete(job: Job, result: any) { 32 | this.logger.debug( 33 | `Completed job ${job.id} of type ${job.name}. Result: ${JSON.stringify( 34 | result 35 | )}` 36 | ); 37 | } 38 | 39 | @OnQueueFailed() 40 | onError(job: Job, error: any) { 41 | this.logger.error( 42 | `Failed job ${job.id} of type ${job.name}: ${error.message}`, 43 | error.stack 44 | ); 45 | } 46 | 47 | @Process('system-mail') 48 | async sendEmail( 49 | job: Job<{ 50 | payload: MailJobInterface; 51 | type: string; 52 | }> 53 | ): Promise { 54 | this.logger.log(`Sending email to '${job.data.payload.to}'`); 55 | const mailConfig = config.get('mail'); 56 | try { 57 | const options: Record = { 58 | to: job.data.payload.to, 59 | from: process.env.MAIL_FROM || mailConfig.fromMail, 60 | subject: job.data.payload.subject, 61 | template: 'email-layout', 62 | context: job.data.payload.context, 63 | attachments: job.data.payload.attachments 64 | }; 65 | return await this.mailerService.sendMail({ ...options }); 66 | } catch (error) { 67 | this.logger.error( 68 | `Failed to send email to '${job.data.payload.to}'`, 69 | error.stack 70 | ); 71 | throw error; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Queue } from 'bull'; 3 | import * as config from 'config'; 4 | import { InjectQueue } from '@nestjs/bull'; 5 | 6 | import { MailJobInterface } from 'src/mail/interface/mail-job.interface'; 7 | import { EmailTemplateService } from 'src/email-template/email-template.service'; 8 | 9 | @Injectable() 10 | export class MailService { 11 | constructor( 12 | @InjectQueue(config.get('mail.queueName')) 13 | private mailQueue: Queue, 14 | private readonly emailTemplateService: EmailTemplateService 15 | ) {} 16 | 17 | /** 18 | * Replace place holder 19 | * @param str 20 | * @param obj 21 | */ 22 | stringInject(str = '', obj = {}) { 23 | let newStr = str; 24 | Object.keys(obj).forEach((key) => { 25 | const placeHolder = `{{${key}}}`; 26 | if (newStr.includes(placeHolder)) { 27 | newStr = newStr.replace(placeHolder, obj[key] || ' '); 28 | } 29 | }); 30 | return newStr; 31 | } 32 | 33 | async sendMail(payload: MailJobInterface, type: string): Promise { 34 | const mailBody = await this.emailTemplateService.findBySlug(payload.slug); 35 | payload.context.content = this.stringInject(mailBody.body, payload.context); 36 | if (mailBody) { 37 | try { 38 | await this.mailQueue.add(type, { 39 | payload 40 | }); 41 | return true; 42 | } catch (error) { 43 | return false; 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/mail/templates/email/activate-account.pug: -------------------------------------------------------------------------------- 1 | extends layouts/email-layout 2 | include mixins/_button 3 | block preHeader 4 | - let previewText = subject 5 | 6 | block content 7 | table.main(role='presentation') 8 | tbody 9 | tr 10 | td.wrapper 11 | table(role='presentation' border='0' cellpadding='0' cellspacing='0') 12 | tbody 13 | tr 14 | td 15 | p Hi #{username}, 16 | p 17 | | A new account has been created using your email #{email}. Click below button to activate your account. 18 | +button(link, 'Activate Account →') 19 | p 20 | | If you haven't requested the code please ignore the email. 21 | p Thank you!. 22 | -------------------------------------------------------------------------------- /src/mail/templates/email/assets/css/style.css: -------------------------------------------------------------------------------- 1 | img { 2 | border: none; 3 | -ms-interpolation-mode: bicubic; 4 | max-width: 100%; 5 | } 6 | 7 | body { 8 | background-color: #f6f6f6; 9 | font-family: sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | font-size: 14px; 12 | line-height: 1.4; 13 | margin: 0; 14 | padding: 0; 15 | -ms-text-size-adjust: 100%; 16 | -webkit-text-size-adjust: 100%; 17 | } 18 | 19 | .body { 20 | background-color: #f6f6f6; 21 | width: 100%; 22 | } 23 | 24 | /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ 25 | .container { 26 | display: block; 27 | margin: 0 auto !important; 28 | /* makes it centered */ 29 | max-width: 580px; 30 | padding: 10px; 31 | width: 580px; 32 | } 33 | 34 | /* This should also be a block element, so that it will fill 100% of the .container */ 35 | .content { 36 | box-sizing: border-box; 37 | display: block; 38 | margin: 0 auto; 39 | max-width: 580px; 40 | padding: 10px; 41 | } 42 | 43 | /* ------------------------------------- 44 | HEADER, FOOTER, MAIN 45 | ------------------------------------- */ 46 | .main { 47 | background: #ffffff; 48 | border-radius: 3px; 49 | width: 100%; 50 | } 51 | 52 | .wrapper { 53 | box-sizing: border-box; 54 | padding: 20px; 55 | } 56 | 57 | .content-block { 58 | padding-bottom: 10px; 59 | padding-top: 10px; 60 | } 61 | 62 | .footer { 63 | clear: both; 64 | margin-top: 10px; 65 | text-align: center; 66 | width: 100%; 67 | } 68 | .footer td, 69 | .footer p, 70 | .footer span, 71 | .footer a { 72 | color: #999999; 73 | font-size: 12px; 74 | text-align: center; 75 | } 76 | 77 | /* ------------------------------------- 78 | TYPOGRAPHY 79 | ------------------------------------- */ 80 | h1, 81 | h2, 82 | h3, 83 | h4 { 84 | color: #000000; 85 | font-family: sans-serif; 86 | font-weight: 400; 87 | line-height: 1.4; 88 | margin: 0; 89 | margin-bottom: 30px; 90 | } 91 | 92 | h1 { 93 | font-size: 35px; 94 | font-weight: 300; 95 | text-align: center; 96 | text-transform: capitalize; 97 | } 98 | 99 | p, 100 | ul, 101 | ol { 102 | font-family: sans-serif; 103 | font-size: 14px; 104 | font-weight: normal; 105 | margin: 0; 106 | margin-bottom: 15px; 107 | } 108 | p li, 109 | ul li, 110 | ol li { 111 | list-style-position: inside; 112 | margin-left: 5px; 113 | } 114 | 115 | a { 116 | color: #3498db; 117 | text-decoration: underline; 118 | } 119 | 120 | /* ------------------------------------- 121 | OTHER STYLES THAT MIGHT BE USEFUL 122 | ------------------------------------- */ 123 | .last { 124 | margin-bottom: 0; 125 | } 126 | 127 | .first { 128 | margin-top: 0; 129 | } 130 | 131 | .align-center { 132 | text-align: center; 133 | } 134 | 135 | .align-right { 136 | text-align: right; 137 | } 138 | 139 | .align-left { 140 | text-align: left; 141 | } 142 | 143 | .clear { 144 | clear: both; 145 | } 146 | 147 | .mt0 { 148 | margin-top: 0; 149 | } 150 | 151 | .mb0 { 152 | margin-bottom: 0; 153 | } 154 | 155 | .preheader { 156 | color: transparent; 157 | display: none; 158 | height: 0; 159 | max-height: 0; 160 | max-width: 0; 161 | opacity: 0; 162 | overflow: hidden; 163 | mso-hide: all; 164 | visibility: hidden; 165 | width: 0; 166 | } 167 | 168 | .powered-by a { 169 | text-decoration: none; 170 | } 171 | 172 | hr { 173 | border: 0; 174 | border-bottom: 1px solid #f6f6f6; 175 | margin: 20px 0; 176 | } 177 | 178 | .content a { 179 | background-color: #3498db; 180 | border-color: #3498db; 181 | color: #ffffff; 182 | border-radius: 5px; 183 | box-sizing: border-box; 184 | cursor: pointer; 185 | display: inline-block; 186 | font-size: 14px; 187 | font-weight: bold; 188 | margin: 0; 189 | padding: 12px 25px; 190 | text-decoration: none; 191 | text-transform: capitalize; 192 | } 193 | -------------------------------------------------------------------------------- /src/mail/templates/email/layouts/email-layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | head 4 | meta(name='viewport' content='width=device-width') 5 | meta(http-equiv='Content-Type' content='text/html; charset=UTF-8') 6 | style 7 | include ../assets/css/style.css 8 | title Truthy CMS 9 | body 10 | span.preheader #{subject} 11 | table.body(role='presentation' border='0' cellpadding='0' cellspacing='0') 12 | tbody 13 | tr 14 | td   15 | td.container 16 | .content !{content} 17 | include ../partials/footer 18 | -------------------------------------------------------------------------------- /src/mail/templates/email/mixins/_button.pug: -------------------------------------------------------------------------------- 1 | mixin button(url, text) 2 | table.btn.btn-primary(role='presentation' border='0' cellpadding='0' cellspacing='0') 3 | tbody 4 | tr 5 | td(align='left') 6 | table(role='presentation' border='0' cellpadding='0' cellspacing='0') 7 | tbody 8 | tr 9 | td 10 | a(href=url, target='_blank')= text 11 | -------------------------------------------------------------------------------- /src/mail/templates/email/partials/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | table(role='presentation' border='0' cellpadding='0' cellspacing='0') 3 | tbody 4 | //tr 5 | // td.content-block 6 | // span.Truthy CMS 7 | // br 8 | // | Don't like these emails? 9 | // a(href='https://github.com/gobeam/truthy') Unsubscribe 10 | // | . 11 | tr 12 | td.content-block.powered-by 13 | | Powered by 14 | a(href='https://github.com/gobeam/truthy') Truthy CMS 15 | | . 16 | -------------------------------------------------------------------------------- /src/mail/templates/email/password-reset.pug: -------------------------------------------------------------------------------- 1 | extends layouts/email-layout 2 | include mixins/_button 3 | block preHeader 4 | - let previewText = subject 5 | 6 | block content 7 | table.main(role='presentation') 8 | tbody 9 | tr 10 | td.wrapper 11 | table(role='presentation' border='0' cellpadding='0' cellspacing='0') 12 | tbody 13 | tr 14 | td 15 | p Hi #{username}, 16 | p 17 | | You have requested a #{subject.toLowerCase()}. Please use following code to complete the action. Please note this link is only valid for the next hour. 18 | +button(link, `${subject} →`) 19 | p 20 | | If you haven't requested the code please ignore the email. 21 | p Thank you!. 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ValidationPipe } from '@nestjs/common'; 3 | import { useContainer } from 'class-validator'; 4 | import * as config from 'config'; 5 | import helmet from 'helmet'; 6 | import { 7 | DocumentBuilder, 8 | SwaggerCustomOptions, 9 | SwaggerModule 10 | } from '@nestjs/swagger'; 11 | import * as cookieParser from 'cookie-parser'; 12 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 13 | 14 | import { AppModule } from 'src/app.module'; 15 | 16 | async function bootstrap() { 17 | const serverConfig = config.get('server'); 18 | const port = process.env.PORT || serverConfig.port; 19 | const app = await NestFactory.create(AppModule); 20 | app.use(helmet()); 21 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); 22 | const apiConfig = config.get('app'); 23 | if (process.env.NODE_ENV === 'development') { 24 | app.enableCors({ 25 | origin: true, 26 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', 27 | credentials: true 28 | }); 29 | const swaggerConfig = new DocumentBuilder() 30 | .setTitle(apiConfig.name) 31 | .setDescription(apiConfig.description) 32 | .setVersion(apiConfig.version) 33 | .addBearerAuth() 34 | .build(); 35 | const customOptions: SwaggerCustomOptions = { 36 | swaggerOptions: { 37 | persistAuthorization: true 38 | }, 39 | customSiteTitle: apiConfig.description 40 | }; 41 | const document = SwaggerModule.createDocument(app, swaggerConfig); 42 | SwaggerModule.setup('api-docs', app, document, customOptions); 43 | } else { 44 | const whitelist = [apiConfig.get('frontendUrl')]; 45 | app.enableCors({ 46 | origin: function (origin, callback) { 47 | if (!origin || whitelist.indexOf(origin) !== -1) { 48 | callback(null, true); 49 | } else { 50 | callback(new Error('Not allowed by CORS')); 51 | } 52 | }, 53 | credentials: true 54 | }); 55 | } 56 | useContainer(app.select(AppModule), { 57 | fallbackOnErrors: true 58 | }); 59 | app.useGlobalPipes( 60 | new ValidationPipe({ 61 | transform: true, 62 | whitelist: true, 63 | forbidNonWhitelisted: true 64 | }) 65 | ); 66 | 67 | app.use(cookieParser()); 68 | await app.listen(port); 69 | console.log(`Application listening in port: ${port}`); 70 | } 71 | 72 | bootstrap(); 73 | -------------------------------------------------------------------------------- /src/paginate/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'src/paginate/pagination.results.interface'; 2 | export * from 'src/paginate/pagination'; 3 | -------------------------------------------------------------------------------- /src/paginate/pagination-info.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PaginationInfoInterface { 2 | skip: number; 3 | limit: number; 4 | page: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/paginate/pagination.results.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PaginationResultInterface { 2 | results: PaginationEntity[]; 3 | currentPage: number; 4 | pageSize: number; 5 | totalItems: number; 6 | next: number; 7 | previous: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/paginate/pagination.ts: -------------------------------------------------------------------------------- 1 | import { PaginationResultInterface } from 'src/paginate/pagination.results.interface'; 2 | export class Pagination { 3 | public results: PaginationEntity[]; 4 | public currentPage: number; 5 | public pageSize: number; 6 | public totalItems: number; 7 | public next: number; 8 | public previous: number; 9 | 10 | constructor(paginationResults: PaginationResultInterface) { 11 | this.results = paginationResults.results; 12 | this.currentPage = paginationResults.currentPage; 13 | this.pageSize = paginationResults.pageSize; 14 | this.totalItems = paginationResults.totalItems; 15 | this.next = paginationResults.next; 16 | this.previous = paginationResults.previous; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/permission/dto/create-permission.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsIn, 3 | IsNotEmpty, 4 | IsString, 5 | MaxLength, 6 | Validate 7 | } from 'class-validator'; 8 | 9 | import { MethodList } from 'src/config/permission-config'; 10 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 11 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 12 | 13 | const methodListArray = [ 14 | MethodList.GET, 15 | MethodList.POST, 16 | MethodList.ANY, 17 | MethodList.DELETE, 18 | MethodList.OPTIONS, 19 | MethodList.OPTIONS 20 | ]; 21 | 22 | export class CreatePermissionDto { 23 | @IsNotEmpty() 24 | @IsString() 25 | @MaxLength(50, { 26 | message: 'maxLength-{"ln":50,"count":50}' 27 | }) 28 | resource: string; 29 | 30 | @IsNotEmpty() 31 | @IsString() 32 | @Validate(UniqueValidatorPipe, [PermissionEntity], { 33 | message: 'already taken' 34 | }) 35 | description: string; 36 | 37 | @IsNotEmpty() 38 | @IsString() 39 | @MaxLength(50, { 40 | message: 'maxLength-{"ln":50,"count":50}' 41 | }) 42 | path: string; 43 | 44 | @IsNotEmpty() 45 | @IsIn(methodListArray, { 46 | message: `isIn-{"items":"${methodListArray.join(',')}"}` 47 | }) 48 | method: MethodList; 49 | } 50 | -------------------------------------------------------------------------------- /src/permission/dto/permission-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | 3 | import { CommonSearchFieldDto } from 'src/common/extra/common-search-field.dto'; 4 | 5 | export class PermissionFilterDto extends PartialType(CommonSearchFieldDto) {} 6 | -------------------------------------------------------------------------------- /src/permission/dto/update-permission.dto.ts: -------------------------------------------------------------------------------- 1 | import { Optional } from '@nestjs/common'; 2 | import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; 3 | import { IsString } from 'class-validator'; 4 | 5 | import { CreatePermissionDto } from 'src/permission/dto/create-permission.dto'; 6 | 7 | export class UpdatePermissionDto extends PartialType(CreatePermissionDto) { 8 | @ApiPropertyOptional() 9 | @Optional() 10 | @IsString() 11 | description: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/permission/entities/permission.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index, ManyToMany, Unique } from 'typeorm'; 2 | 3 | import { CustomBaseEntity } from 'src/common/entity/custom-base.entity'; 4 | import { RoleEntity } from 'src/role/entities/role.entity'; 5 | 6 | @Entity({ 7 | name: 'permission' 8 | }) 9 | @Unique(['description']) 10 | export class PermissionEntity extends CustomBaseEntity { 11 | @Column('varchar', { length: 100 }) 12 | resource: string; 13 | 14 | @Column() 15 | @Index({ 16 | unique: true 17 | }) 18 | description: string; 19 | 20 | @Column() 21 | path: string; 22 | 23 | @Column('varchar', { 24 | default: 'get', 25 | length: 20 26 | }) 27 | method: string; 28 | 29 | @Column() 30 | isDefault: boolean; 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | @ManyToMany((type) => RoleEntity, (role) => role.permission) 34 | role: RoleEntity[]; 35 | 36 | constructor(data?: Partial) { 37 | super(); 38 | if (data) { 39 | Object.assign(this, data); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/permission/misc/load-permission.misc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ModulesPayloadInterface, 3 | PermissionPayload, 4 | RoutePayloadInterface, 5 | SubModulePayloadInterface 6 | } from 'src/config/permission-config'; 7 | 8 | export class LoadPermissionMisc { 9 | assignResourceAndConcatPermission( 10 | modules: ModulesPayloadInterface | SubModulePayloadInterface, 11 | permissionsList: RoutePayloadInterface[], 12 | resource: string, 13 | isDefault?: false 14 | ) { 15 | if (modules.permissions) { 16 | for (const permission of modules.permissions) { 17 | permissionsList = this.concatPermissions( 18 | permission, 19 | permissionsList, 20 | resource, 21 | isDefault 22 | ); 23 | } 24 | } 25 | return permissionsList; 26 | } 27 | 28 | concatPermissions( 29 | permission: PermissionPayload, 30 | permissionsList: RoutePayloadInterface[], 31 | resource: string, 32 | isDefault: boolean 33 | ) { 34 | const description = permission.name; 35 | for (const data of permission.route) { 36 | data.resource = data.resource || resource; 37 | data.description = data.description || description; 38 | data.isDefault = isDefault; 39 | } 40 | return permissionsList.concat(permission.route); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/permission/permission.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from 'typeorm'; 2 | import { classToPlain, plainToClass } from 'class-transformer'; 3 | 4 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 5 | import { BaseRepository } from 'src/common/repository/base.repository'; 6 | import { Permission } from 'src/permission/serializer/permission.serializer'; 7 | import { RoutePayloadInterface } from 'src/config/permission-config'; 8 | 9 | @EntityRepository(PermissionEntity) 10 | export class PermissionRepository extends BaseRepository< 11 | PermissionEntity, 12 | Permission 13 | > { 14 | async syncPermission( 15 | permissionsList: RoutePayloadInterface[] 16 | ): Promise { 17 | await this.createQueryBuilder('permission') 18 | .insert() 19 | .into(PermissionEntity) 20 | .values(permissionsList) 21 | .orIgnore() 22 | .execute(); 23 | } 24 | 25 | transform(model: PermissionEntity, transformOption = {}): Permission { 26 | return plainToClass( 27 | Permission, 28 | classToPlain(model, transformOption), 29 | transformOption 30 | ); 31 | } 32 | 33 | transformMany( 34 | models: PermissionEntity[], 35 | transformOption = {} 36 | ): Permission[] { 37 | return models.map((model) => this.transform(model, transformOption)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/permission/permissions.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Put, 11 | Query, 12 | UseGuards 13 | } from '@nestjs/common'; 14 | import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; 15 | 16 | import { PermissionsService } from 'src/permission/permissions.service'; 17 | import { CreatePermissionDto } from 'src/permission/dto/create-permission.dto'; 18 | import { UpdatePermissionDto } from 'src/permission/dto/update-permission.dto'; 19 | import { PermissionFilterDto } from 'src/permission/dto/permission-filter.dto'; 20 | import { Permission } from 'src/permission/serializer/permission.serializer'; 21 | import { PermissionGuard } from 'src/common/guard/permission.guard'; 22 | import { Pagination } from 'src/paginate'; 23 | import JwtTwoFactorGuard from 'src/common/guard/jwt-two-factor.guard'; 24 | 25 | @ApiTags('permissions') 26 | @UseGuards(JwtTwoFactorGuard, PermissionGuard) 27 | @Controller('permissions') 28 | @ApiBearerAuth() 29 | export class PermissionsController { 30 | constructor(private readonly permissionsService: PermissionsService) {} 31 | 32 | @Post() 33 | create( 34 | @Body() 35 | createPermissionDto: CreatePermissionDto 36 | ): Promise { 37 | return this.permissionsService.create(createPermissionDto); 38 | } 39 | 40 | @Get() 41 | @ApiQuery({ 42 | type: PermissionFilterDto 43 | }) 44 | findAll( 45 | @Query() 46 | permissionFilterDto: PermissionFilterDto 47 | ): Promise> { 48 | return this.permissionsService.findAll(permissionFilterDto); 49 | } 50 | 51 | @Get(':id') 52 | findOne( 53 | @Param('id') 54 | id: string 55 | ): Promise { 56 | return this.permissionsService.findOne(+id); 57 | } 58 | 59 | @Put(':id') 60 | update( 61 | @Param('id') 62 | id: string, 63 | @Body() 64 | updatePermissionDto: UpdatePermissionDto 65 | ): Promise { 66 | return this.permissionsService.update(+id, updatePermissionDto); 67 | } 68 | 69 | @Delete(':id') 70 | @HttpCode(HttpStatus.NO_CONTENT) 71 | remove( 72 | @Param('id') 73 | id: string 74 | ): Promise { 75 | return this.permissionsService.remove(+id); 76 | } 77 | 78 | @Post('/sync') 79 | @HttpCode(HttpStatus.NO_CONTENT) 80 | syncPermission(): Promise { 81 | return this.permissionsService.syncPermission(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/permission/permissions.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { PermissionsService } from 'src/permission/permissions.service'; 5 | import { PermissionsController } from 'src/permission/permissions.controller'; 6 | import { PermissionRepository } from 'src/permission/permission.repository'; 7 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 8 | import { AuthModule } from 'src/auth/auth.module'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([PermissionRepository]), AuthModule], 12 | exports: [PermissionsService], 13 | controllers: [PermissionsController], 14 | providers: [PermissionsService, UniqueValidatorPipe] 15 | }) 16 | export class PermissionsModule {} 17 | -------------------------------------------------------------------------------- /src/permission/serializer/permission.serializer.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | import { ModelSerializer } from 'src/common/serializer/model.serializer'; 5 | 6 | export const basicFieldGroupsForSerializing: string[] = ['basic']; 7 | 8 | export class Permission extends ModelSerializer { 9 | @Expose({ 10 | groups: basicFieldGroupsForSerializing 11 | }) 12 | id: number; 13 | 14 | @ApiProperty() 15 | resource: string; 16 | 17 | @ApiProperty() 18 | @Expose({ 19 | groups: basicFieldGroupsForSerializing 20 | }) 21 | description: string; 22 | 23 | @ApiProperty() 24 | path: string; 25 | 26 | @ApiProperty() 27 | method: string; 28 | 29 | @ApiProperty() 30 | @Expose({ 31 | groups: basicFieldGroupsForSerializing 32 | }) 33 | isDefault: boolean; 34 | 35 | @ApiPropertyOptional() 36 | @Expose({ 37 | groups: basicFieldGroupsForSerializing 38 | }) 39 | createdAt: Date; 40 | 41 | @ApiPropertyOptional() 42 | @Expose({ 43 | groups: basicFieldGroupsForSerializing 44 | }) 45 | updatedAt: Date; 46 | } 47 | -------------------------------------------------------------------------------- /src/refresh-token/dto/refresh-paginate-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CommonSearchFieldDto } from 'src/common/extra/common-search-field.dto'; 3 | 4 | export class RefreshPaginateFilterDto extends PartialType( 5 | CommonSearchFieldDto 6 | ) {} 7 | -------------------------------------------------------------------------------- /src/refresh-token/entities/refresh-token.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | Index, 6 | PrimaryGeneratedColumn 7 | } from 'typeorm'; 8 | 9 | @Entity({ 10 | name: 'refresh_token' 11 | }) 12 | export class RefreshToken extends BaseEntity { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Column() 17 | userId: number; 18 | 19 | @Column() 20 | ip: string; 21 | 22 | @Column() 23 | userAgent: string; 24 | 25 | @Index() 26 | @Column({ 27 | nullable: true 28 | }) 29 | browser: string; 30 | 31 | @Index() 32 | @Column({ 33 | nullable: true 34 | }) 35 | os: string; 36 | 37 | @Column() 38 | isRevoked: boolean; 39 | 40 | @Column() 41 | expires: Date; 42 | } 43 | -------------------------------------------------------------------------------- /src/refresh-token/interface/refresh-token.interface.ts: -------------------------------------------------------------------------------- 1 | export interface RefreshTokenInterface { 2 | jwtid: number; 3 | subject: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/refresh-token/refresh-token.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RefreshTokenService } from 'src/refresh-token/refresh-token.service'; 5 | import { AuthModule } from 'src/auth/auth.module'; 6 | import { RefreshTokenRepository } from 'src/refresh-token/refresh-token.repository'; 7 | 8 | @Module({ 9 | imports: [ 10 | forwardRef(() => AuthModule), 11 | TypeOrmModule.forFeature([RefreshTokenRepository]) 12 | ], 13 | providers: [RefreshTokenService], 14 | exports: [RefreshTokenService], 15 | controllers: [] 16 | }) 17 | export class RefreshTokenModule {} 18 | -------------------------------------------------------------------------------- /src/refresh-token/refresh-token.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from 'typeorm'; 2 | import * as config from 'config'; 3 | 4 | import { RefreshToken } from 'src/refresh-token/entities/refresh-token.entity'; 5 | import { UserSerializer } from 'src/auth/serializer/user.serializer'; 6 | import { BaseRepository } from 'src/common/repository/base.repository'; 7 | import { RefreshTokenSerializer } from 'src/refresh-token/serializer/refresh-token.serializer'; 8 | 9 | const tokenConfig = config.get('jwt'); 10 | @EntityRepository(RefreshToken) 11 | export class RefreshTokenRepository extends BaseRepository< 12 | RefreshToken, 13 | RefreshTokenSerializer 14 | > { 15 | /** 16 | * Create refresh token 17 | * @param user 18 | * @param tokenPayload 19 | */ 20 | public async createRefreshToken( 21 | user: UserSerializer, 22 | tokenPayload: Partial 23 | ): Promise { 24 | const token = this.create(); 25 | token.userId = user.id; 26 | token.isRevoked = false; 27 | token.ip = tokenPayload.ip; 28 | token.userAgent = tokenPayload.userAgent; 29 | token.browser = tokenPayload.browser; 30 | token.os = tokenPayload.os; 31 | const expiration = new Date(); 32 | expiration.setSeconds( 33 | expiration.getSeconds() + tokenConfig.refreshExpiresIn 34 | ); 35 | token.expires = expiration; 36 | return token.save(); 37 | } 38 | 39 | /** 40 | * find token by id 41 | * @param id 42 | */ 43 | public async findTokenById(id: number): Promise { 44 | return this.findOne({ 45 | where: { 46 | id 47 | } 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/refresh-token/serializer/refresh-token.serializer.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ModelSerializer } from 'src/common/serializer/model.serializer'; 3 | 4 | export class RefreshTokenSerializer extends ModelSerializer { 5 | id: number; 6 | 7 | @ApiProperty() 8 | userId: number; 9 | 10 | @ApiProperty() 11 | ip: string; 12 | 13 | @ApiProperty() 14 | userAgent: string; 15 | 16 | @ApiProperty() 17 | browser: string; 18 | 19 | @ApiProperty() 20 | os: string; 21 | 22 | @ApiProperty() 23 | isRevoked: boolean; 24 | 25 | @ApiProperty() 26 | expires: Date; 27 | } 28 | -------------------------------------------------------------------------------- /src/role/dto/create-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsNumber, 4 | IsString, 5 | MaxLength, 6 | MinLength, 7 | Validate, 8 | ValidateIf 9 | } from 'class-validator'; 10 | 11 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 12 | import { RoleEntity } from 'src/role/entities/role.entity'; 13 | 14 | export class CreateRoleDto { 15 | @IsNotEmpty() 16 | @IsString() 17 | @MinLength(2, { 18 | message: 'minLength-{"ln":2,"count":2}' 19 | }) 20 | @MaxLength(100, { 21 | message: 'maxLength-{"ln":100,"count":100}' 22 | }) 23 | @Validate(UniqueValidatorPipe, [RoleEntity], { 24 | message: 'already taken' 25 | }) 26 | name: string; 27 | 28 | @ValidateIf((object, value) => value) 29 | @IsString() 30 | description: string; 31 | 32 | @ValidateIf((object, value) => value) 33 | @IsNumber( 34 | {}, 35 | { 36 | each: true, 37 | message: 'should be array of numbers' 38 | } 39 | ) 40 | permissions: number[]; 41 | } 42 | -------------------------------------------------------------------------------- /src/role/dto/role-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | 3 | import { CommonSearchFieldDto } from 'src/common/extra/common-search-field.dto'; 4 | 5 | export class RoleFilterDto extends PartialType(CommonSearchFieldDto) {} 6 | -------------------------------------------------------------------------------- /src/role/dto/update-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength, MinLength, ValidateIf } from 'class-validator'; 2 | import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; 3 | 4 | import { CreateRoleDto } from 'src/role/dto/create-role.dto'; 5 | 6 | export class UpdateRoleDto extends PartialType(CreateRoleDto) { 7 | @ApiPropertyOptional() 8 | @ValidateIf((object, value) => value) 9 | @IsString() 10 | @MinLength(2) 11 | @MaxLength(100) 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/role/entities/role.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index, JoinTable, ManyToMany, Unique } from 'typeorm'; 2 | 3 | import { CustomBaseEntity } from 'src/common/entity/custom-base.entity'; 4 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 5 | 6 | @Entity({ 7 | name: 'role' 8 | }) 9 | @Unique(['name']) 10 | export class RoleEntity extends CustomBaseEntity { 11 | @Column('varchar', { length: 100 }) 12 | @Index({ 13 | unique: true 14 | }) 15 | name: string; 16 | 17 | @Column('text') 18 | description: string; 19 | 20 | @ManyToMany(() => PermissionEntity, (permission) => permission.role) 21 | @JoinTable({ 22 | name: 'role_permission', 23 | joinColumn: { 24 | name: 'roleId', 25 | referencedColumnName: 'id' 26 | }, 27 | inverseJoinColumn: { 28 | name: 'permissionId', 29 | referencedColumnName: 'id' 30 | } 31 | }) 32 | permission: PermissionEntity[]; 33 | 34 | constructor(data?: Partial) { 35 | super(); 36 | if (data) { 37 | Object.assign(this, data); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/role/role.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository } from 'typeorm'; 2 | import { classToPlain, plainToClass } from 'class-transformer'; 3 | 4 | import { RoleEntity } from 'src/role/entities/role.entity'; 5 | import { RoleSerializer } from 'src/role/serializer/role.serializer'; 6 | import { BaseRepository } from 'src/common/repository/base.repository'; 7 | import { CreateRoleDto } from 'src/role/dto/create-role.dto'; 8 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 9 | import { UpdateRoleDto } from 'src/role/dto/update-role.dto'; 10 | 11 | @EntityRepository(RoleEntity) 12 | export class RoleRepository extends BaseRepository { 13 | async store( 14 | createRoleDto: CreateRoleDto, 15 | permissions: PermissionEntity[] 16 | ): Promise { 17 | const { name, description } = createRoleDto; 18 | const role = this.create(); 19 | role.name = name; 20 | role.description = description; 21 | role.permission = permissions; 22 | await role.save(); 23 | return this.transform(role); 24 | } 25 | 26 | async updateItem( 27 | role: RoleEntity, 28 | updateRoleDto: UpdateRoleDto, 29 | permission: PermissionEntity[] 30 | ): Promise { 31 | const fields = ['name', 'description']; 32 | for (const field of fields) { 33 | if (updateRoleDto[field]) { 34 | role[field] = updateRoleDto[field]; 35 | } 36 | } 37 | if (permission && permission.length > 0) { 38 | role.permission = permission; 39 | } 40 | await role.save(); 41 | return this.transform(role); 42 | } 43 | 44 | /** 45 | * transform single role 46 | * @param model 47 | * @param transformOption 48 | */ 49 | transform(model: RoleEntity, transformOption = {}): RoleSerializer { 50 | return plainToClass( 51 | RoleSerializer, 52 | classToPlain(model, transformOption), 53 | transformOption 54 | ); 55 | } 56 | 57 | /** 58 | * transform array of roles 59 | * @param models 60 | * @param transformOption 61 | */ 62 | transformMany(models: RoleEntity[], transformOption = {}): RoleSerializer[] { 63 | return models.map((model) => this.transform(model, transformOption)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/role/roles.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Put, 11 | Query, 12 | UseGuards 13 | } from '@nestjs/common'; 14 | import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger'; 15 | 16 | import { RolesService } from 'src/role/roles.service'; 17 | import { CreateRoleDto } from 'src/role/dto/create-role.dto'; 18 | import { UpdateRoleDto } from 'src/role/dto/update-role.dto'; 19 | import { RoleFilterDto } from 'src/role/dto/role-filter.dto'; 20 | import { RoleSerializer } from 'src/role/serializer/role.serializer'; 21 | import { Pagination } from 'src/paginate'; 22 | import { PermissionGuard } from 'src/common/guard/permission.guard'; 23 | import JwtTwoFactorGuard from 'src/common/guard/jwt-two-factor.guard'; 24 | 25 | @ApiTags('roles') 26 | @UseGuards(JwtTwoFactorGuard, PermissionGuard) 27 | @Controller('roles') 28 | @ApiBearerAuth() 29 | export class RolesController { 30 | constructor(private readonly rolesService: RolesService) {} 31 | 32 | @Post() 33 | create( 34 | @Body() 35 | createRoleDto: CreateRoleDto 36 | ): Promise { 37 | return this.rolesService.create(createRoleDto); 38 | } 39 | 40 | @Get() 41 | @ApiQuery({ 42 | type: RoleFilterDto 43 | }) 44 | findAll( 45 | @Query() 46 | roleFilterDto: RoleFilterDto 47 | ): Promise> { 48 | return this.rolesService.findAll(roleFilterDto); 49 | } 50 | 51 | @Get(':id') 52 | findOne( 53 | @Param('id') 54 | id: string 55 | ): Promise { 56 | return this.rolesService.findOne(+id); 57 | } 58 | 59 | @Put(':id') 60 | update( 61 | @Param('id') 62 | id: string, 63 | @Body() 64 | updateRoleDto: UpdateRoleDto 65 | ): Promise { 66 | return this.rolesService.update(+id, updateRoleDto); 67 | } 68 | 69 | @Delete(':id') 70 | @HttpCode(HttpStatus.NO_CONTENT) 71 | remove( 72 | @Param('id') 73 | id: string 74 | ): Promise { 75 | return this.rolesService.remove(+id); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/role/roles.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RolesService } from 'src/role/roles.service'; 5 | import { RolesController } from 'src/role/roles.controller'; 6 | import { RoleRepository } from 'src/role/role.repository'; 7 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 8 | import { AuthModule } from 'src/auth/auth.module'; 9 | import { PermissionsModule } from 'src/permission/permissions.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([RoleRepository]), 14 | AuthModule, 15 | PermissionsModule 16 | ], 17 | exports: [], 18 | controllers: [RolesController], 19 | providers: [RolesService, UniqueValidatorPipe] 20 | }) 21 | export class RolesModule {} 22 | -------------------------------------------------------------------------------- /src/role/roles.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnprocessableEntityException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Not, ObjectLiteral } from 'typeorm'; 4 | 5 | import { NotFoundException } from 'src/exception/not-found.exception'; 6 | import { CreateRoleDto } from 'src/role/dto/create-role.dto'; 7 | import { UpdateRoleDto } from 'src/role/dto/update-role.dto'; 8 | import { RoleRepository } from 'src/role/role.repository'; 9 | import { RoleFilterDto } from 'src/role/dto/role-filter.dto'; 10 | import { 11 | adminUserGroupsForSerializing, 12 | basicFieldGroupsForSerializing, 13 | RoleSerializer 14 | } from 'src/role/serializer/role.serializer'; 15 | import { CommonServiceInterface } from 'src/common/interfaces/common-service.interface'; 16 | import { PermissionsService } from 'src/permission/permissions.service'; 17 | import { Pagination } from 'src/paginate'; 18 | 19 | @Injectable() 20 | export class RolesService implements CommonServiceInterface { 21 | constructor( 22 | @InjectRepository(RoleRepository) 23 | private repository: RoleRepository, 24 | private readonly permissionsService: PermissionsService 25 | ) {} 26 | 27 | /** 28 | * Get Permission Id array 29 | * @param ids 30 | */ 31 | async getPermissionByIds(ids) { 32 | if (ids && ids.length > 0) { 33 | return await this.permissionsService.whereInIds(ids); 34 | } 35 | return []; 36 | } 37 | 38 | /** 39 | * Find by name 40 | * @param name 41 | */ 42 | async findByName(name) { 43 | return await this.repository.findOne({ name }); 44 | } 45 | 46 | /** 47 | * create new role 48 | * @param createRoleDto 49 | */ 50 | async create(createRoleDto: CreateRoleDto): Promise { 51 | const { permissions } = createRoleDto; 52 | const permission = await this.getPermissionByIds(permissions); 53 | return this.repository.store(createRoleDto, permission); 54 | } 55 | 56 | /** 57 | * find and return collection of roles 58 | * @param roleFilterDto 59 | */ 60 | async findAll( 61 | roleFilterDto: RoleFilterDto 62 | ): Promise> { 63 | return this.repository.paginate( 64 | roleFilterDto, 65 | [], 66 | ['name', 'description'], 67 | { 68 | groups: [ 69 | ...adminUserGroupsForSerializing, 70 | ...basicFieldGroupsForSerializing 71 | ] 72 | } 73 | ); 74 | } 75 | 76 | /** 77 | * find role by id 78 | * @param id 79 | */ 80 | async findOne(id: number): Promise { 81 | return this.repository.get(id, ['permission'], { 82 | groups: [ 83 | ...adminUserGroupsForSerializing, 84 | ...basicFieldGroupsForSerializing 85 | ] 86 | }); 87 | } 88 | 89 | /** 90 | * update role by id 91 | * @param id 92 | * @param updateRoleDto 93 | */ 94 | async update( 95 | id: number, 96 | updateRoleDto: UpdateRoleDto 97 | ): Promise { 98 | const role = await this.repository.findOne(id); 99 | if (!role) { 100 | throw new NotFoundException(); 101 | } 102 | const condition: ObjectLiteral = { 103 | name: updateRoleDto.name 104 | }; 105 | condition.id = Not(id); 106 | const checkUniqueTitle = await this.repository.countEntityByCondition( 107 | condition 108 | ); 109 | if (checkUniqueTitle > 0) { 110 | throw new UnprocessableEntityException({ 111 | property: 'name', 112 | constraints: { 113 | unique: 'already taken' 114 | } 115 | }); 116 | } 117 | const { permissions } = updateRoleDto; 118 | const permission = await this.getPermissionByIds(permissions); 119 | return this.repository.updateItem(role, updateRoleDto, permission); 120 | } 121 | 122 | /** 123 | * remove role by id 124 | * @param id 125 | */ 126 | async remove(id: number): Promise { 127 | await this.findOne(id); 128 | await this.repository.delete({ id }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/role/serializer/role.serializer.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Expose, Type } from 'class-transformer'; 3 | 4 | import { ModelSerializer } from 'src/common/serializer/model.serializer'; 5 | import { Permission } from 'src/permission/serializer/permission.serializer'; 6 | 7 | export const adminUserGroupsForSerializing: string[] = ['admin']; 8 | export const basicFieldGroupsForSerializing: string[] = ['basic']; 9 | 10 | export class RoleSerializer extends ModelSerializer { 11 | id: number; 12 | 13 | @ApiProperty() 14 | name: string; 15 | 16 | @ApiPropertyOptional() 17 | @Expose({ 18 | groups: basicFieldGroupsForSerializing 19 | }) 20 | description: string; 21 | 22 | @Type(() => Permission) 23 | permission: Permission[]; 24 | 25 | @ApiPropertyOptional() 26 | @Expose({ 27 | groups: basicFieldGroupsForSerializing 28 | }) 29 | createdAt: Date; 30 | 31 | @ApiPropertyOptional() 32 | @Expose({ 33 | groups: basicFieldGroupsForSerializing 34 | }) 35 | updatedAt: Date; 36 | } 37 | -------------------------------------------------------------------------------- /src/twofa/dto/twofa-code.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | export class TwofaCodeDto { 4 | @IsNotEmpty() 5 | code: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/twofa/dto/twofa-status-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | 3 | export class TwoFaStatusUpdateDto { 4 | @IsBoolean() 5 | isTwoFAEnabled: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/twofa/twofa.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | Put, 8 | Req, 9 | Res, 10 | UnauthorizedException, 11 | UseGuards 12 | } from '@nestjs/common'; 13 | import { Request, Response } from 'express'; 14 | 15 | import { AuthService } from 'src/auth/auth.service'; 16 | import { UserEntity } from 'src/auth/entity/user.entity'; 17 | import { GetUser } from 'src/common/decorators/get-user.decorator'; 18 | import { JwtAuthGuard } from 'src/common/guard/jwt-auth.guard'; 19 | import { TwofaCodeDto } from 'src/twofa/dto/twofa-code.dto'; 20 | import { TwoFaStatusUpdateDto } from 'src/twofa/dto/twofa-status-update.dto'; 21 | import { TwofaService } from 'src/twofa/twofa.service'; 22 | 23 | @Controller('twofa') 24 | export class TwofaController { 25 | constructor( 26 | private readonly twofaService: TwofaService, 27 | private readonly usersService: AuthService 28 | ) {} 29 | 30 | @Post('authenticate') 31 | @HttpCode(200) 32 | @UseGuards(JwtAuthGuard) 33 | async authenticate( 34 | @Req() 35 | req: Request, 36 | @Res() 37 | response: Response, 38 | @GetUser() 39 | user: UserEntity, 40 | @Body() 41 | twofaCodeDto: TwofaCodeDto 42 | ) { 43 | const isCodeValid = this.twofaService.isTwoFACodeValid( 44 | twofaCodeDto.code, 45 | user 46 | ); 47 | if (!isCodeValid) { 48 | throw new UnauthorizedException('invalidOTP'); 49 | } 50 | const accessToken = await this.usersService.generateAccessToken(user, true); 51 | const cookiePayload = this.usersService.buildResponsePayload(accessToken); 52 | response.setHeader('Set-Cookie', cookiePayload); 53 | return response.status(HttpStatus.NO_CONTENT).json({}); 54 | } 55 | 56 | @Put() 57 | @HttpCode(HttpStatus.NO_CONTENT) 58 | @UseGuards(JwtAuthGuard) 59 | async toggleTwoFa( 60 | @Body() 61 | twofaStatusUpdateDto: TwoFaStatusUpdateDto, 62 | @GetUser() 63 | user: UserEntity 64 | ) { 65 | let qrDataUri = null; 66 | if (twofaStatusUpdateDto.isTwoFAEnabled) { 67 | const { otpauthUrl } = await this.twofaService.generateTwoFASecret(user); 68 | qrDataUri = await this.twofaService.qrDataToUrl(otpauthUrl); 69 | } 70 | return this.usersService.turnOnTwoFactorAuthentication( 71 | user, 72 | twofaStatusUpdateDto.isTwoFAEnabled, 73 | qrDataUri 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/twofa/twofa.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { TwofaService } from 'src/twofa/twofa.service'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | import { TwofaController } from 'src/twofa/twofa.controller'; 6 | 7 | @Module({ 8 | providers: [TwofaService], 9 | imports: [AuthModule], 10 | exports: [TwofaService], 11 | controllers: [TwofaController] 12 | }) 13 | export class TwofaModule {} 14 | -------------------------------------------------------------------------------- /src/twofa/twofa.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, Injectable } from '@nestjs/common'; 2 | import * as config from 'config'; 3 | import { Response } from 'express'; 4 | import { authenticator } from 'otplib'; 5 | import { toFileStream, toDataURL } from 'qrcode'; 6 | 7 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 8 | import { AuthService } from 'src/auth/auth.service'; 9 | import { UserEntity } from 'src/auth/entity/user.entity'; 10 | import { CustomHttpException } from 'src/exception/custom-http.exception'; 11 | 12 | const TwofaConfig = config.get('twofa'); 13 | 14 | @Injectable() 15 | export class TwofaService { 16 | constructor(private readonly usersService: AuthService) {} 17 | 18 | async generateTwoFASecret(user: UserEntity) { 19 | if (user.twoFAThrottleTime > new Date()) { 20 | throw new CustomHttpException( 21 | `tooManyRequest-{"second":"${this.differentBetweenDatesInSec( 22 | user.twoFAThrottleTime, 23 | new Date() 24 | )}"}`, 25 | HttpStatus.TOO_MANY_REQUESTS, 26 | StatusCodesList.TooManyTries 27 | ); 28 | } 29 | const secret = authenticator.generateSecret(); 30 | const otpauthUrl = authenticator.keyuri( 31 | user.email, 32 | TwofaConfig.authenticationAppNAme, 33 | secret 34 | ); 35 | await this.usersService.setTwoFactorAuthenticationSecret(secret, user.id); 36 | return { 37 | secret, 38 | otpauthUrl 39 | }; 40 | } 41 | 42 | isTwoFACodeValid(twoFASecret: string, user: UserEntity) { 43 | return authenticator.verify({ 44 | token: twoFASecret, 45 | secret: user.twoFASecret 46 | }); 47 | } 48 | 49 | async pipeQrCodeStream(stream: Response, otpauthUrl: string) { 50 | return toFileStream(stream, otpauthUrl); 51 | } 52 | 53 | async qrDataToUrl(otpauthUrl: string): Promise { 54 | return toDataURL(otpauthUrl); 55 | } 56 | 57 | differentBetweenDatesInSec(initialDate: Date, endDate: Date): number { 58 | const diffInSeconds = Math.abs(initialDate.getTime() - endDate.getTime()); 59 | return Math.round(diffInSeconds / 1000); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/e2e/app/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | 3 | import { AppFactory } from 'test/factories/app'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app: AppFactory; 7 | 8 | beforeAll(async () => { 9 | await AppFactory.dropTables(); 10 | app = await AppFactory.new(); 11 | }); 12 | 13 | beforeEach(async () => { 14 | await AppFactory.cleanupDB(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.instance.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect({ message: 'hello world' }); 22 | }); 23 | 24 | afterAll(async () => { 25 | await app.close(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/e2e/auth/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import * as request from 'supertest'; 3 | 4 | import { AppFactory } from 'test/factories/app'; 5 | import { RoleFactory } from 'test/factories/role.factory'; 6 | import { UserFactory } from 'test/factories/user.factory'; 7 | import { extractCookies } from 'test/utility/extract-cookie'; 8 | 9 | describe('AuthController (e2e)', () => { 10 | let app: AppFactory; 11 | 12 | beforeAll(async () => { 13 | await AppFactory.dropTables(); 14 | app = await AppFactory.new(); 15 | }); 16 | 17 | beforeEach(async () => { 18 | await AppFactory.cleanupDB(); 19 | }); 20 | 21 | it('POST /auth/login requires valid username and password', async () => { 22 | await request(app.instance.getHttpServer()) 23 | .post(`/auth/login`) 24 | .send({ 25 | username: 'email' 26 | }) 27 | .expect(HttpStatus.UNPROCESSABLE_ENTITY); 28 | }); 29 | 30 | it('POST /auth/login should throw unauthorized error if wrong username and password provided', async () => { 31 | await request(app.instance.getHttpServer()) 32 | .post(`/auth/login`) 33 | .send({ 34 | username: 'john@example.com', 35 | password: 'wrongPassword', 36 | remember: true 37 | }) 38 | .expect(HttpStatus.UNAUTHORIZED); 39 | }); 40 | 41 | it('POST /auth/login should login if provided with valid username and password', async () => { 42 | let cookie; 43 | const role = await RoleFactory.new().create(); 44 | const user = await UserFactory.new() 45 | .withRole(role) 46 | .create({ password: 'password' }); 47 | 48 | await request(app.instance.getHttpServer()) 49 | .post(`/auth/login`) 50 | .send({ 51 | username: user.email, 52 | password: 'password', 53 | remember: true 54 | }) 55 | .expect(HttpStatus.NO_CONTENT) 56 | .then((res) => { 57 | cookie = extractCookies(res.headers); 58 | }); 59 | expect(cookie).toBeDefined(); 60 | expect(cookie).toHaveProperty('Authentication'); 61 | expect(cookie).toHaveProperty('Refresh'); 62 | expect(cookie).toHaveProperty('ExpiresIn'); 63 | }); 64 | 65 | afterAll(async () => { 66 | await app.close(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/e2e/example.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppFactory } from 'test/factories/app'; 2 | 3 | describe('Example Test (e2e)', () => { 4 | let app: AppFactory; 5 | 6 | beforeAll(async () => { 7 | await AppFactory.dropTables(); 8 | app = await AppFactory.new(); 9 | }); 10 | 11 | beforeEach(async () => { 12 | await AppFactory.cleanupDB(); 13 | }); 14 | 15 | it('it passes', async () => { 16 | expect(true).toBe(true); 17 | }); 18 | 19 | afterAll(async () => { 20 | await app.close(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/e2e/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "../../", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "modulePaths": ["."] 10 | } 11 | -------------------------------------------------------------------------------- /test/factories/app.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerModule } from '@nestjs/throttler'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { Test } from '@nestjs/testing'; 4 | import { createConnection, getConnection } from 'typeorm'; 5 | import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; 6 | import { RateLimiterRedis } from 'rate-limiter-flexible'; 7 | import * as Redis from 'ioredis'; 8 | import * as config from 'config'; 9 | 10 | import { AppModule } from 'src/app.module'; 11 | 12 | const dbConfig = config.get('db'); 13 | 14 | export class AppFactory { 15 | private constructor( 16 | private readonly appInstance: INestApplication, 17 | private readonly redis: Redis.Redis 18 | ) {} 19 | 20 | get instance() { 21 | return this.appInstance; 22 | } 23 | 24 | static async new() { 25 | const redis = await setupRedis(); 26 | const moduleBuilder = Test.createTestingModule({ 27 | imports: [ 28 | AppModule, 29 | ThrottlerModule.forRootAsync({ 30 | useFactory: () => { 31 | return { 32 | ttl: 60, 33 | limit: 60, 34 | storage: new ThrottlerStorageRedisService(redis) 35 | }; 36 | } 37 | }) 38 | ] 39 | }) 40 | .overrideProvider('LOGIN_THROTTLE') 41 | .useFactory({ 42 | factory: () => { 43 | return new RateLimiterRedis({ 44 | storeClient: redis, 45 | keyPrefix: 'login', 46 | points: 5, 47 | duration: 60 * 60 * 24 * 30, // Store number for 30 days since first fail 48 | blockDuration: 3000 49 | }); 50 | } 51 | }); 52 | 53 | const module = await moduleBuilder.compile(); 54 | 55 | const app = module.createNestApplication(undefined, { 56 | logger: false 57 | }); 58 | 59 | await app.init(); 60 | 61 | return new AppFactory(app, redis); 62 | } 63 | 64 | async close() { 65 | await getConnection().dropDatabase(); 66 | if (this.redis) await this.teardown(this.redis); 67 | if (this.appInstance) await this.appInstance.close(); 68 | } 69 | 70 | static async cleanupDB() { 71 | const connection = getConnection(); 72 | const tables = connection.entityMetadatas.map( 73 | (entity) => `"${entity.tableName}"` 74 | ); 75 | 76 | for (const table of tables) { 77 | await connection.query(`DELETE FROM ${table};`); 78 | } 79 | } 80 | 81 | static async dropTables() { 82 | const connection = await createConnection({ 83 | type: dbConfig.type || 'postgres', 84 | host: process.env.DB_HOST || dbConfig.host, 85 | port: parseInt(process.env.DB_PORT) || dbConfig.port, 86 | database: process.env.DB_DATABASE_NAME || dbConfig.database, 87 | username: process.env.DB_USERNAME || dbConfig.username, 88 | password: process.env.DB_PASSWORD || dbConfig.password 89 | }); 90 | 91 | await connection.query(`SET session_replication_role = 'replica';`); 92 | const tables = connection.entityMetadatas.map( 93 | (entity) => `"${entity.tableName}"` 94 | ); 95 | for (const tableName of tables) { 96 | await connection.query(`DROP TABLE IF EXISTS ${tableName};`); 97 | } 98 | 99 | await connection.close(); 100 | } 101 | 102 | async teardown(redis: Redis.Redis) { 103 | return new Promise((resolve) => { 104 | redis.quit(); 105 | redis.on('end', () => { 106 | resolve(); 107 | }); 108 | }); 109 | } 110 | } 111 | 112 | const setupRedis = async () => { 113 | const redis = new Redis({ 114 | host: process.env.REDIS_HOST || 'localhost', 115 | port: parseInt(process.env.REDIS_PORT) || 6379 116 | }); 117 | await redis.flushall(); 118 | return redis; 119 | }; 120 | -------------------------------------------------------------------------------- /test/factories/role.factory.ts: -------------------------------------------------------------------------------- 1 | import { getRepository } from 'typeorm'; 2 | import { faker } from '@faker-js/faker'; 3 | 4 | import { RoleEntity } from 'src/role/entities/role.entity'; 5 | 6 | export class RoleFactory { 7 | static new() { 8 | return new RoleFactory(); 9 | } 10 | 11 | async create(role: Partial = {}) { 12 | const roleRepository = getRepository(RoleEntity); 13 | return roleRepository.save({ 14 | name: faker.name.jobTitle(), 15 | description: faker.lorem.sentence(), 16 | ...role 17 | }); 18 | } 19 | 20 | async createMany(roles: Partial[]) { 21 | return Promise.all([roles.map((role) => this.create(role))]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/factories/throttle.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ThrottlerModule } from '@nestjs/throttler'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: ThrottlerModule, 9 | useValue: { 10 | limit: 10, 11 | ttl: 1000, 12 | storage: jest.fn() 13 | } // mock 14 | } 15 | ] 16 | }) 17 | export default class Throttle {} 18 | -------------------------------------------------------------------------------- /test/factories/user.factory.ts: -------------------------------------------------------------------------------- 1 | import { getRepository } from 'typeorm'; 2 | import { faker } from '@faker-js/faker'; 3 | import * as bcrypt from 'bcrypt'; 4 | 5 | import { UserEntity } from 'src/auth/entity/user.entity'; 6 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 7 | import { RoleEntity } from 'src/role/entities/role.entity'; 8 | 9 | export class UserFactory { 10 | private role: RoleEntity; 11 | 12 | static new() { 13 | return new UserFactory(); 14 | } 15 | 16 | withRole(role: RoleEntity) { 17 | this.role = role; 18 | return this; 19 | } 20 | 21 | async create(user: Partial = {}) { 22 | const userRepository = getRepository(UserEntity); 23 | const salt = await bcrypt.genSalt(); 24 | const password = await this.hashPassword( 25 | user.password || faker.internet.password(), 26 | salt 27 | ); 28 | const payload = { 29 | username: faker.internet.userName().toLowerCase(), 30 | email: faker.internet.email().toLowerCase(), 31 | name: `${faker.name.firstName()} ${faker.name.lastName()}`, 32 | address: faker.address.streetAddress(), 33 | contact: faker.phone.phoneNumber(), 34 | avatar: faker.image.avatar(), 35 | salt, 36 | token: faker.datatype.uuid(), 37 | status: UserStatusEnum.ACTIVE, 38 | isTwoFAEnabled: false, 39 | ...user, 40 | password 41 | }; 42 | 43 | if (this.role) payload.role = this.role; 44 | return userRepository.save(payload); 45 | } 46 | 47 | async createMany(users: Partial[]) { 48 | return Promise.all([users.map((user) => this.create(user))]); 49 | } 50 | 51 | private hashPassword(password, salt) { 52 | return bcrypt.hash(password, salt); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/unit/auth/entity/user.entity.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | 3 | import { UserEntity } from 'src/auth/entity/user.entity'; 4 | 5 | describe('test validate password', () => { 6 | let user: UserEntity; 7 | beforeEach(async () => { 8 | user = new UserEntity(); 9 | user.password = 'testPassword'; 10 | user.salt = 'testSalt'; 11 | bcrypt.hash = jest.fn(); 12 | }); 13 | it('validate password if password matches', async () => { 14 | bcrypt.hash.mockResolvedValue('testPassword'); 15 | const check = await user.validatePassword('123456'); 16 | expect(bcrypt.hash).toHaveBeenCalledWith('123456', 'testSalt'); 17 | expect(check).toBeTruthy(); 18 | }); 19 | it('validate password if password dont matches', async () => { 20 | bcrypt.hash.mockResolvedValue('anotherPassword'); 21 | const check = await user.validatePassword('123456'); 22 | expect(bcrypt.hash).toHaveBeenCalledWith('123456', 'testSalt'); 23 | expect(check).toBeFalsy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/unit/auth/pipes/username-unique-validation.pipes.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { AuthService } from 'src/auth/auth.service'; 4 | import { IsUsernameAlreadyExist } from 'src/auth/pipes/username-unique-validation.pipes'; 5 | 6 | const mockAuthService = () => ({ 7 | findBy: jest.fn() 8 | }); 9 | describe('IsUsernameAlreadyExist', () => { 10 | let authService: AuthService, isUsernameAlreadyExist: IsUsernameAlreadyExist; 11 | beforeEach(async () => { 12 | const module = await Test.createTestingModule({ 13 | providers: [ 14 | IsUsernameAlreadyExist, 15 | { 16 | provide: AuthService, 17 | useFactory: mockAuthService 18 | } 19 | ] 20 | }).compile(); 21 | isUsernameAlreadyExist = await module.get( 22 | IsUsernameAlreadyExist 23 | ); 24 | authService = await module.get(AuthService); 25 | }); 26 | 27 | describe('username unique validation', () => { 28 | it('check for same username', async () => { 29 | expect(authService.findBy).not.toHaveBeenCalled(); 30 | const result = await isUsernameAlreadyExist.validate('tester'); 31 | expect(authService.findBy).toHaveBeenCalledWith('username', 'tester'); 32 | expect(result).toBe(true); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/unit/auth/user.repository.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { UserRepository } from 'src/auth/user.repository'; 4 | import { CreateUserDto } from 'src/auth/dto/create-user.dto'; 5 | import { UserLoginDto } from 'src/auth/dto/user-login.dto'; 6 | import { UserEntity } from 'src/auth/entity/user.entity'; 7 | import { UserStatusEnum } from 'src/auth/user-status.enum'; 8 | import { StatusCodesList } from 'src/common/constants/status-codes-list.constants'; 9 | import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants'; 10 | 11 | const mockUser = { 12 | roleId: 1, 13 | email: 'test@email.com', 14 | username: 'tester', 15 | name: 'test', 16 | status: UserStatusEnum.ACTIVE, 17 | password: 'pwd' 18 | }; 19 | describe('User Repository', () => { 20 | let userRepository; 21 | beforeEach(async () => { 22 | const module = await Test.createTestingModule({ 23 | providers: [UserRepository] 24 | }).compile(); 25 | userRepository = await module.get(UserRepository); 26 | }); 27 | 28 | describe('store', () => { 29 | let save; 30 | beforeEach(async () => { 31 | save = jest.fn().mockResolvedValue(undefined); 32 | userRepository.create = jest.fn().mockReturnValue({ save }); 33 | }); 34 | it('store new user', async () => { 35 | const createUserDto: CreateUserDto = { 36 | ...mockUser 37 | }; 38 | await expect(userRepository.store(createUserDto)).resolves.not.toThrow(); 39 | }); 40 | }); 41 | 42 | describe('login', () => { 43 | let user, userLoginDto: UserLoginDto; 44 | beforeEach(async () => { 45 | userRepository.findOne = jest.fn(); 46 | user = new UserEntity(); 47 | user.status = UserStatusEnum.ACTIVE; 48 | user.username = mockUser.username; 49 | user.password = mockUser.password; 50 | user.validatePassword = jest.fn(); 51 | userLoginDto = { 52 | ...mockUser, 53 | remember: true 54 | }; 55 | }); 56 | it('check if username and password matches and return user', async () => { 57 | userRepository.findOne.mockResolvedValue(user); 58 | user.validatePassword.mockResolvedValue(true); 59 | const result = await userRepository.login(userLoginDto); 60 | expect(userRepository.findOne).toHaveBeenCalledWith({ 61 | where: [ 62 | { 63 | username: userLoginDto.username 64 | }, 65 | { 66 | email: userLoginDto.username 67 | } 68 | ] 69 | }); 70 | expect(result).toEqual([user, null, null]); 71 | }); 72 | 73 | it('throw error if username and password does not matches', async () => { 74 | userRepository.findOne.mockResolvedValue(user); 75 | user.validatePassword.mockResolvedValue(false); 76 | const result = await userRepository.login(userLoginDto); 77 | expect(result).toEqual([ 78 | null, 79 | ExceptionTitleList.InvalidCredentials, 80 | StatusCodesList.InvalidCredentials 81 | ]); 82 | }); 83 | 84 | it('check if user is null', async () => { 85 | userRepository.findOne.mockResolvedValue(null); 86 | const result = await userRepository.login(userLoginDto); 87 | expect(result).toEqual([ 88 | null, 89 | ExceptionTitleList.InvalidCredentials, 90 | StatusCodesList.InvalidCredentials 91 | ]); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/unit/common/guard/permission.guard.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { PermissionGuard } from 'src/common/guard/permission.guard'; 4 | import { UserEntity } from 'src/auth/entity/user.entity'; 5 | import { RoleEntity } from 'src/role/entities/role.entity'; 6 | import { PermissionEntity } from 'src/permission/entities/permission.entity'; 7 | 8 | describe('Permission guard test', () => { 9 | let permissionGuard; 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [PermissionGuard] 13 | }).compile(); 14 | permissionGuard = module.get(PermissionGuard); 15 | jest.clearAllMocks(); 16 | }); 17 | 18 | describe('check if user have necessary permission', () => { 19 | let context, mockPermission, mockRole, mockUser, mockSwitchToHttp; 20 | beforeEach(() => { 21 | context = { 22 | getArgByIndex: jest.fn(), 23 | getArgs: jest.fn(), 24 | getClass: jest.fn(), 25 | getHandler: jest.fn(), 26 | getType: jest.fn(), 27 | switchToRpc: jest.fn(), 28 | switchToWs: jest.fn(), 29 | switchToHttp: jest.fn() 30 | }; 31 | 32 | mockPermission = new PermissionEntity(); 33 | mockPermission.method = 'get'; 34 | mockPermission.path = '/roles/:id'; 35 | 36 | mockRole = new RoleEntity(); 37 | mockRole.name = 'tester'; 38 | mockRole.permission = [mockPermission]; 39 | 40 | mockUser = new UserEntity(); 41 | mockUser.name = 'truthy'; 42 | mockUser.username = 'truthy'; 43 | mockUser.role = mockRole; 44 | 45 | mockSwitchToHttp = { 46 | getRequest: jest.fn(), 47 | getNext: jest.fn(), 48 | getResponse: jest.fn() 49 | }; 50 | }); 51 | it('check if permission is granted if user have valid permission', async () => { 52 | const requestMockData = { 53 | route: { 54 | path: '/roles/:id' 55 | }, 56 | method: 'GET', 57 | user: mockUser 58 | }; 59 | mockSwitchToHttp.getRequest.mockReturnValue(requestMockData); 60 | context.switchToHttp.mockReturnValue(mockSwitchToHttp); 61 | permissionGuard.checkIfDefaultRoute = jest.fn(); 62 | const result = await permissionGuard.canActivate(context); 63 | expect(permissionGuard.checkIfDefaultRoute).toHaveBeenCalledTimes(1); 64 | expect(result).toBeTruthy(); 65 | }); 66 | 67 | it("check if permission is granted if user don't have valid permission", async () => { 68 | const requestMockData = { 69 | route: { 70 | path: '/permission/:id' 71 | }, 72 | method: 'GET', 73 | user: mockUser 74 | }; 75 | mockSwitchToHttp.getRequest.mockReturnValue(requestMockData); 76 | context.switchToHttp.mockReturnValue(mockSwitchToHttp); 77 | const result = await permissionGuard.canActivate(context); 78 | expect(result).toBeFalsy(); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/unit/common/pipes/unique-validator.pipe.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { getConnectionToken } from '@nestjs/typeorm'; 3 | import { Connection } from 'typeorm'; 4 | 5 | import { UniqueValidatorPipe } from 'src/common/pipes/unique-validator.pipe'; 6 | import { UserEntity } from 'src/auth/entity/user.entity'; 7 | import { UniqueValidationArguments } from 'src/common/pipes/abstract-unique-validator'; 8 | 9 | const mockConnection = () => ({ 10 | getRepository: jest.fn(() => ({ 11 | count: jest.fn().mockResolvedValue(0) 12 | })) 13 | }); 14 | 15 | describe('UniqueValidatorPipe', () => { 16 | let isUnique: UniqueValidatorPipe, connection; 17 | beforeEach(async () => { 18 | const module = await Test.createTestingModule({ 19 | providers: [ 20 | UniqueValidatorPipe, 21 | { 22 | provide: getConnectionToken(), 23 | useFactory: mockConnection 24 | } 25 | ] 26 | }).compile(); 27 | isUnique = await module.get(UniqueValidatorPipe); 28 | connection = await module.get(Connection); 29 | }); 30 | 31 | describe('check unique validation', () => { 32 | it('check if there is no duplicate', async () => { 33 | const username = 'tester'; 34 | const args: UniqueValidationArguments = { 35 | constraints: [UserEntity, ({ object: {} }) => ({})], 36 | value: username, 37 | targetName: '', 38 | object: { 39 | username 40 | }, 41 | property: 'username' 42 | }; 43 | const result = await isUnique.validate('username', args); 44 | expect(connection.getRepository).toHaveBeenCalledWith(UserEntity); 45 | expect(result).toBe(true); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/unit/common/strategy/jwt.strategy.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { JwtStrategy } from 'src/common/strategy/jwt.strategy'; 4 | import { UserRepository } from 'src/auth/user.repository'; 5 | import { UserEntity } from 'src/auth/entity/user.entity'; 6 | import { JwtPayloadDto } from 'src/auth/dto/jwt-payload.dto'; 7 | import { UnauthorizedException } from 'src/exception/unauthorized.exception'; 8 | 9 | const mockUserRepository = () => ({ 10 | findOne: jest.fn() 11 | }); 12 | 13 | describe('Test JWT strategy', () => { 14 | let userRepository, jwtStrategy: JwtStrategy; 15 | beforeEach(async () => { 16 | jest.mock('config', () => ({ 17 | default: { 18 | get: () => jest.fn().mockImplementation(() => 'hello') 19 | } 20 | })); 21 | const module = await Test.createTestingModule({ 22 | providers: [ 23 | JwtStrategy, 24 | { 25 | provide: UserRepository, 26 | useFactory: mockUserRepository 27 | } 28 | ] 29 | }).compile(); 30 | jwtStrategy = await module.get(JwtStrategy); 31 | userRepository = await module.get(UserRepository); 32 | }); 33 | 34 | describe('validate user', () => { 35 | it('should return user if username is found on database', async () => { 36 | const user = new UserEntity(); 37 | user.name = 'test'; 38 | user.username = 'tester'; 39 | const payload: JwtPayloadDto = { 40 | subject: '1' 41 | }; 42 | userRepository.findOne.mockResolvedValue(user); 43 | const result = await jwtStrategy.validate(payload); 44 | expect(userRepository.findOne).toHaveBeenCalledWith( 45 | Number(payload.subject), 46 | { 47 | relations: ['role', 'role.permission'] 48 | } 49 | ); 50 | expect(result).toEqual(user); 51 | }); 52 | 53 | it('should throw error if subject is not found on database', async () => { 54 | const payload: JwtPayloadDto = { 55 | subject: '1' 56 | }; 57 | userRepository.findOne.mockResolvedValue(null); 58 | await expect(jwtStrategy.validate(payload)).rejects.toThrow( 59 | UnauthorizedException 60 | ); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/unit/dashboard/dashboard.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from 'src/auth/auth.service'; 3 | import { DashboardService } from 'src/dashboard/dashboard.service'; 4 | 5 | const authServiceMock = () => ({ 6 | countByCondition: jest.fn(), 7 | getRefreshTokenGroupedData: jest.fn() 8 | }); 9 | 10 | describe('DashboardService', () => { 11 | let service: DashboardService, authService; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | providers: [ 16 | DashboardService, 17 | { 18 | provide: AuthService, 19 | useFactory: authServiceMock 20 | } 21 | ] 22 | }).compile(); 23 | 24 | service = module.get(DashboardService); 25 | authService = module.get(AuthService); 26 | }); 27 | 28 | it('should get User Stat', () => { 29 | const result = service.getUserStat(); 30 | expect(authService.countByCondition).toHaveBeenCalledTimes(3); 31 | expect(result).resolves.not.toThrow(); 32 | }); 33 | 34 | it('should get browser Stat', () => { 35 | service.getBrowserData(); 36 | expect(authService.getRefreshTokenGroupedData).toHaveBeenCalledTimes(1); 37 | expect(authService.getRefreshTokenGroupedData).toHaveBeenCalledWith( 38 | 'browser' 39 | ); 40 | }); 41 | 42 | it('should get os Stat', () => { 43 | service.getOsData(); 44 | expect(authService.getRefreshTokenGroupedData).toHaveBeenCalledTimes(1); 45 | expect(authService.getRefreshTokenGroupedData).toHaveBeenCalledWith('os'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/unit/jest-unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "../../", 4 | "testEnvironment": "node", 5 | "testRegex": ".unit-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "modulePaths": ["."] 10 | } 11 | -------------------------------------------------------------------------------- /test/unit/refresh-token/refresh-token.repository.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { RefreshTokenRepository } from 'src/refresh-token/refresh-token.repository'; 4 | import { UserSerializer } from 'src/auth/serializer/user.serializer'; 5 | 6 | const mockRefreshToken = { 7 | id: 1, 8 | userId: 1, 9 | expires: new Date(), 10 | isRevoked: false, 11 | save: jest.fn() 12 | }; 13 | 14 | describe('Refresh token repository', () => { 15 | let repository, user; 16 | beforeEach(async () => { 17 | const module = await Test.createTestingModule({ 18 | providers: [RefreshTokenRepository] 19 | }).compile(); 20 | repository = await module.get( 21 | RefreshTokenRepository 22 | ); 23 | user = new UserSerializer(); 24 | user.id = 1; 25 | user.email = 'test@mail.com'; 26 | }); 27 | 28 | it('create new refresh token', async () => { 29 | jest.spyOn(repository, 'create').mockReturnValue(mockRefreshToken); 30 | await repository.createRefreshToken(user, 60 * 60); 31 | expect(repository.create).toHaveBeenCalledTimes(1); 32 | expect(repository.create().save).toHaveBeenCalledTimes(1); 33 | }); 34 | 35 | it('findTokenById', async () => { 36 | jest.spyOn(repository, 'findOne').mockReturnValue(mockRefreshToken); 37 | await repository.findTokenById(1); 38 | expect(repository.findOne).toHaveBeenCalledTimes(1); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/unit/twofa/twofa.service.unit-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { authenticator } from 'otplib'; 3 | 4 | import { TwofaService } from 'src/twofa/twofa.service'; 5 | import { AuthService } from 'src/auth/auth.service'; 6 | import { UserEntity } from 'src/auth/entity/user.entity'; 7 | import { CustomHttpException } from 'src/exception/custom-http.exception'; 8 | 9 | const authServiceMock = () => ({ 10 | setTwoFactorAuthenticationSecret: jest.fn() 11 | }); 12 | 13 | describe('TwofaService', () => { 14 | let service: TwofaService, authService, user: UserEntity; 15 | 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | providers: [ 19 | TwofaService, 20 | { 21 | provide: AuthService, 22 | useFactory: authServiceMock 23 | } 24 | ] 25 | }).compile(); 26 | 27 | service = module.get(TwofaService); 28 | authService = await module.get(AuthService); 29 | user = new UserEntity(); 30 | user.email = 'test@mail.com'; 31 | user.username = 'tester'; 32 | user.password = 'pwd'; 33 | user.salt = 'result'; 34 | }); 35 | 36 | afterEach(() => { 37 | jest.clearAllMocks(); 38 | }); 39 | 40 | describe('#generateSecret', () => { 41 | it('should throw error if user tries to spam generate OTP', async () => { 42 | const twoFAThrottleTime = new Date(); 43 | twoFAThrottleTime.setSeconds(twoFAThrottleTime.getSeconds() + 60); 44 | user.twoFAThrottleTime = twoFAThrottleTime; 45 | await expect(service.generateTwoFASecret(user)).rejects.toThrowError( 46 | CustomHttpException 47 | ); 48 | }); 49 | it('should generate 2fa secret', async () => { 50 | jest.spyOn(authenticator, 'generateSecret').mockReturnValue('result'); 51 | jest.spyOn(authenticator, 'keyuri').mockReturnValue('result'); 52 | await service.generateTwoFASecret(user); 53 | expect(authenticator.generateSecret).toHaveBeenCalledTimes(1); 54 | expect(authenticator.keyuri).toHaveBeenCalledTimes(1); 55 | expect( 56 | authService.setTwoFactorAuthenticationSecret 57 | ).toHaveBeenCalledTimes(1); 58 | }); 59 | }); 60 | 61 | it('isTwoFACodeValid', async () => { 62 | jest.spyOn(authenticator, 'verify').mockReturnValue(true); 63 | service.isTwoFACodeValid('secret', user); 64 | expect(authenticator.verify).toHaveBeenCalledTimes(1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/utility/create-mock.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Provider } from '@nestjs/common'; 2 | 3 | export const createMockModule = (providers: Provider[]): DynamicModule => { 4 | const exports = providers.map( 5 | (provider) => (provider as any).provide || provider 6 | ); 7 | return { 8 | module: class MockModule {}, 9 | providers, 10 | exports, 11 | global: true 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /test/utility/extract-cookie.ts: -------------------------------------------------------------------------------- 1 | const shapeFlags = (flags) => 2 | flags.reduce((shapedFlags, flag) => { 3 | const [flagName, rawValue] = flag.split('='); 4 | const value = rawValue ? rawValue.replace(';', '') : true; 5 | return { ...shapedFlags, [flagName]: value }; 6 | }, {}); 7 | 8 | export const extractCookies = (headers) => { 9 | const cookies = headers['set-cookie']; 10 | 11 | return cookies.reduce((shapedCookies, cookieString) => { 12 | const [rawCookie, ...flags] = cookieString.split('; '); 13 | const [cookieName, value] = rawCookie.split('='); 14 | return { 15 | ...shapedCookies, 16 | [cookieName]: { value, flags: shapeFlags(flags) } 17 | }; 18 | }, {}); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | --------------------------------------------------------------------------------