├── .commitlintrc.js ├── .cspell.json ├── .czrc ├── .dockerignore ├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── FUNDING.md ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 01-bug.yml │ ├── 02-documentation.yml │ ├── 03-feature.yml │ └── 04-tooling.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── actions │ └── setup-environment │ │ └── action.yml ├── renovate.json └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .markdownlint.json ├── .markdownlintignore ├── .ncurc.js ├── .npmignore ├── .npmpackagejsonlintignore ├── .npmpackagejsonlintrc.json ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .tool-versions ├── .versionrc.js ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docker ├── Dockerfile └── docker-compose.yml ├── jest.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── application │ ├── health │ │ ├── check-health-status.request.ts │ │ ├── check-health-status.usecase.ts │ │ ├── health-status.response.ts │ │ └── index.ts │ ├── sessions │ │ ├── end │ │ │ ├── end-session.request.ts │ │ │ ├── end-session.usecase.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── refresh │ │ │ ├── index.ts │ │ │ ├── refresh-session.request.ts │ │ │ └── refresh-session.usecase.ts │ │ ├── session.response.ts │ │ ├── start │ │ │ ├── index.ts │ │ │ ├── start-session.request.ts │ │ │ └── start-session.usecase.ts │ │ └── validate │ │ │ ├── index.ts │ │ │ ├── validate-session.request.ts │ │ │ ├── validate-session.usecase.ts │ │ │ └── validated-session.response.ts │ ├── shared │ │ ├── base-usecase.ts │ │ ├── index.ts │ │ ├── usecase.decorator.ts │ │ └── usecase.request.ts │ └── users │ │ ├── authentication │ │ ├── authenticate-user.request.ts │ │ ├── authenticate-user.usecase.ts │ │ └── index.ts │ │ ├── find │ │ ├── find-user.request.ts │ │ ├── find-user.usecase.ts │ │ └── index.ts │ │ ├── index.ts │ │ ├── search-all │ │ ├── index.ts │ │ ├── search-all-users.request.ts │ │ └── search-all-users.usecase.ts │ │ └── user.response.ts ├── domain │ ├── health │ │ ├── health-status.ts │ │ └── index.ts │ ├── sessions │ │ ├── index.ts │ │ ├── invalid-session.exception.ts │ │ ├── session-expires-at.ts │ │ ├── session-id.ts │ │ ├── session-refresh-token-hash.ts │ │ ├── session-revoked-at.ts │ │ ├── session-revoked-by.ts │ │ ├── session-revoked-reason.ts │ │ ├── session-user-data.ts │ │ ├── session-user-uuid.ts │ │ ├── session-uuid.ts │ │ ├── session.repository.ts │ │ ├── session.ts │ │ └── tokens │ │ │ ├── access-token.ts │ │ │ ├── index.ts │ │ │ ├── refresh-token.ts │ │ │ ├── token-expires-at.ts │ │ │ ├── token-provider.domain-service.ts │ │ │ └── token.ts │ ├── shared │ │ ├── entities │ │ │ ├── domain-entity.ts │ │ │ ├── index.ts │ │ │ └── triggered-by │ │ │ │ ├── index.ts │ │ │ │ ├── triggered-by-anonymous.ts │ │ │ │ ├── triggered-by-system.ts │ │ │ │ ├── triggered-by-user.ts │ │ │ │ └── triggered-by.ts │ │ ├── exceptions │ │ │ ├── domain.exception.ts │ │ │ ├── index.ts │ │ │ └── invalid-parameter.exception.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── services │ │ │ ├── domain-service.decorator.ts │ │ │ ├── hasher.domain-service.ts │ │ │ ├── index.ts │ │ │ └── logger.domain-service.ts │ │ ├── types.ts │ │ └── value-object │ │ │ ├── composite-value-object.ts │ │ │ ├── date-value-object.ts │ │ │ ├── enum-value-object.ts │ │ │ ├── index.ts │ │ │ ├── number-value-object.ts │ │ │ ├── string-value-object.ts │ │ │ ├── uuid.ts │ │ │ └── value-object.ts │ └── users │ │ ├── authentication │ │ ├── index.ts │ │ ├── invalid-authentication-credentials.exception.ts │ │ └── invalid-authentication-username.exception.ts │ │ ├── index.ts │ │ ├── user-address.ts │ │ ├── user-birth-date.ts │ │ ├── user-email.ts │ │ ├── user-gender.ts │ │ ├── user-id.ts │ │ ├── user-name.ts │ │ ├── user-not-exists.exception.ts │ │ ├── user-password-hash.ts │ │ ├── user-phone-number.ts │ │ ├── user-profile-picture.ts │ │ ├── user-role.ts │ │ ├── user-username.ts │ │ ├── user-uuid.ts │ │ ├── user.repository.ts │ │ └── user.ts ├── healthcheck.ts ├── index.ts ├── infrastructure │ ├── sessions │ │ ├── index.ts │ │ ├── prisma-session.mapper.ts │ │ ├── prisma-session.repository.ts │ │ ├── redis-session.mapper.ts │ │ ├── redis-session.repository.ts │ │ ├── redis-session.ts │ │ └── tokens │ │ │ └── jwt-token-provider.domain-service.ts │ ├── shared │ │ ├── authentication │ │ │ ├── authentication-utils.ts │ │ │ ├── authentication.ts │ │ │ └── index.ts │ │ ├── bootstrap.ts │ │ ├── cache │ │ │ └── cache.ts │ │ ├── config │ │ │ ├── environment.ts │ │ │ ├── index.ts │ │ │ └── infrastructure.config.ts │ │ ├── di │ │ │ └── dependency-injection.ts │ │ ├── index.ts │ │ ├── infrastructure-service.decorator.ts │ │ ├── logger │ │ │ ├── pino-logger.ts │ │ │ └── pino-rotate-file.transport.ts │ │ └── persistence │ │ │ ├── base-repository.ts │ │ │ ├── index.ts │ │ │ ├── prisma │ │ │ ├── prisma-base-repository.ts │ │ │ ├── schema.prisma │ │ │ └── seed.ts │ │ │ ├── redis │ │ │ └── redis-base-repository.ts │ │ │ └── repository.decorator.ts │ └── users │ │ ├── index.ts │ │ ├── prisma-user.mapper.ts │ │ └── prisma-user.repository.ts ├── presentation │ └── rest │ │ ├── config │ │ ├── app.config.ts │ │ └── index.ts │ │ ├── controllers │ │ ├── authentication │ │ │ ├── authenticated-user.api-response.ts │ │ │ ├── authentication.controller.ts │ │ │ └── user-successfully-authenticated.api-response.ts │ │ ├── health │ │ │ ├── health-status.api-response.ts │ │ │ └── health.controller.ts │ │ └── users │ │ │ ├── user.api-response.ts │ │ │ └── user.controller.ts │ │ ├── exceptions │ │ ├── api.exception.ts │ │ ├── bad-request.exception.ts │ │ ├── exception.api-response.ts │ │ ├── forbidden.exception.ts │ │ ├── index.ts │ │ ├── internal-server-error.exception.ts │ │ ├── no-credentials-provided.exception.ts │ │ ├── path-not-found.exception.ts │ │ ├── resource-not-found.exception.ts │ │ └── unauthorized.exception.ts │ │ ├── filters │ │ ├── error-handler.filter.ts │ │ ├── index.ts │ │ └── not-found.filter.ts │ │ ├── middlewares │ │ ├── authentication.middleware.ts │ │ ├── error-handler.middleware.ts │ │ ├── index.ts │ │ ├── logger.middleware.ts │ │ ├── metadata.middleware.ts │ │ └── not-found.middleware.ts │ │ ├── server.ts │ │ └── shared │ │ ├── request.utils.ts │ │ ├── response.utils.ts │ │ ├── rest-controller.decorator.ts │ │ └── with-auth.decorator.ts └── types │ └── global.d.ts ├── test ├── e2e │ ├── health │ │ └── check-health-status.e2e.ts │ └── shared │ │ ├── index.ts │ │ └── test-server.ts ├── integration │ └── health │ │ └── check-health-status.int.ts ├── jest.mocks.ts ├── jest.setup.ts └── unit │ └── healthcheck │ └── health-status-response.unit.ts ├── tsconfig.build.json └── tsconfig.json /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Shareable commitlint config enforcing conventional commits 3 | * See https://commitlint.js.org/#/ 4 | */ 5 | module.exports = { 6 | extends: ['@commitlint/config-conventional'] 7 | }; 8 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en", 4 | "import": [ 5 | "@cspell/dict-typescript", 6 | "@cspell/dict-software-terms", 7 | "@cspell/dict-node", 8 | "@cspell/dict-en_us", 9 | "@cspell/dict-en-gb", 10 | "@cspell/dict-npm", 11 | "@cspell/dict-html", 12 | "@cspell/dict-companies", 13 | "@cspell/dict-filetypes", 14 | "@cspell/dict-bash", 15 | "@cspell/dict-lorem-ipsum/cspell-ext.json", 16 | "@cspell/dict-es-es/cspell-ext.json" 17 | ], 18 | "allowCompoundWords": true, 19 | "flagWords": [], 20 | "ignorePaths": [ 21 | ".eslintcache", 22 | ".vscode/**", 23 | "dist/**", 24 | "node_modules/**", 25 | "package-lock.json", 26 | "package.json", 27 | "tsconfig.json", 28 | "tsconfig.build.json", 29 | "yarn.lock", 30 | "**/coverage/**", 31 | "**/node_modules/**", 32 | "**/dist/**", 33 | "**/fixtures/**", 34 | "**/public/**", 35 | "CHANGELOG.md", 36 | "**/changelog.mdx", 37 | "**/*.prisma", 38 | "docker/", 39 | "Makefile", 40 | "logs/", 41 | "coverage/" 42 | ], 43 | "dictionaries": [ 44 | "typescript", 45 | "softwareTerms", 46 | "node", 47 | "en_us", 48 | "en-gb", 49 | "npm", 50 | "html", 51 | "companies", 52 | "misc", 53 | "filetypes", 54 | "bash", 55 | "lorem-ipsum", 56 | "es-es" 57 | ], 58 | "words": [ 59 | "Awilix", 60 | "backends", 61 | "bitauth", 62 | "bitjson", 63 | "Borja", 64 | "borjapazr", 65 | "cimg", 66 | "circleci", 67 | "codecov", 68 | "Codetour", 69 | "commitlint", 70 | "Containerised", 71 | "dependabot", 72 | "dtos", 73 | "editorconfig", 74 | "endent", 75 | "entrypoint", 76 | "esbenp", 77 | "eslintcache", 78 | "esnext", 79 | "execa", 80 | "exponentiate", 81 | "globby", 82 | "healthcheck", 83 | "healthz", 84 | "hgetall", 85 | "hset", 86 | "inversify", 87 | "joelwmale", 88 | "libauth", 89 | "luxon", 90 | "maxsize", 91 | "Metadatas", 92 | "middlewares", 93 | "Millis", 94 | "mkdir", 95 | "ngneat", 96 | "nodeify", 97 | "openapi", 98 | "prettierignore", 99 | "printf", 100 | "roadmap", 101 | "Rodríguez", 102 | "sandboxed", 103 | "sonarjs", 104 | "topbar", 105 | "Traefik", 106 | "transpiled", 107 | "tsed", 108 | "typedoc", 109 | "unopinionated", 110 | "untracked", 111 | "usecase" 112 | ] 113 | } 114 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .commitlintrc* 2 | .dockerignore 3 | .DS_Store 4 | .editorconfig 5 | .env* 6 | .eslintcache 7 | .eslintrc* 8 | .git 9 | .prettierrc* 10 | .vscode 11 | *.log 12 | *docker-compose* 13 | *Dockerfile* 14 | .docker 15 | coverage 16 | data 17 | dist 18 | LICENSE 19 | logs 20 | node_modules 21 | npm-debug.log 22 | README.md 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{java,gradle,xml}] 12 | indent_size = 4 13 | continuation_indent_size = 8 14 | 15 | [*.py] 16 | indent_size = 4 17 | max_line_length = 80 18 | 19 | [*.php] 20 | indent_size = 4 21 | 22 | [**.min.js] 23 | indent_style = ignore 24 | insert_final_newline = ignore 25 | 26 | [{Makefile,**.mk}] 27 | indent_style = tab 28 | 29 | [*.mk] 30 | indent_style = tab 31 | 32 | [*.bat] 33 | indent_style = tab 34 | 35 | [*.json] 36 | insert_final_newline = ignore 37 | 38 | [*.md] 39 | trim_trailing_whitespace = false 40 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ## App configuration ## 2 | NODE_ENV=development 3 | PORT=5000 4 | SESSIONS_STORAGE=cache # cache or db 5 | LOGS_ENABLED=true 6 | 7 | ## App information overrides ## 8 | # By default, these are read from package.json # 9 | #APP_VERSION= 10 | APP_NAME=express-typescript-skeleton 11 | #APP_DESCRIPTION= 12 | #AUTHOR_NAME= 13 | #AUTHOR_EMAIL= 14 | #AUTHOR_WEBSITE= 15 | 16 | ## Docker configuration ## 17 | # App configuration # 18 | IMAGE_NAME=express-typescript-skeleton 19 | EXTERNAL_PORT=5000 20 | LOGS_VOLUME=../.docker/app/logs 21 | 22 | # Database configuration # 23 | DB_TYPE=postgresql 24 | DB_HOST=localhost 25 | DB_PORT=5432 26 | DB_MANAGER_PORT=5001 27 | DB_USER=mars-user 28 | DB_PASSWORD=mars-password 29 | DB_NAME=express-typescript-skeleton-db 30 | DB_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?connect_timeout=300 31 | DB_VOLUME=../.docker/db/data 32 | 33 | # Cache configuration # 34 | CACHE_HOST=localhost 35 | CACHE_PORT=6379 36 | CACHE_MANAGER_PORT=5002 37 | CACHE_PASSWORD=mars-password 38 | CACHE_DB=0 39 | CACHE_VOLUME=../.docker/cache/data 40 | 41 | # Container configuration 42 | TZ=Europe/Madrid 43 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .commitlintrc* 2 | .docker 3 | .eslintcache 4 | .eslintrc* 5 | .prettierrc* 6 | coverage 7 | dist 8 | jest.config.js 9 | logs 10 | node_modules 11 | package-lock.json 12 | yarn.lock 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @borjapazr 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to `express-typescript-skeleton`! 💖 4 | 5 | > After this page, see [DEVELOPMENT.md](./DEVELOPMENT.md) for local development instructions. 6 | 7 | ## Code of Conduct 8 | 9 | This project contains a [Contributor Covenant code of conduct](./CODE_OF_CONDUCT.md) all contributors are expected to follow. 10 | 11 | ## Reporting Issues 12 | 13 | Please do [report an issue on the issue tracker](https://github.com/borjapazr/express-typescript-skeleton/issues/new/choose) if there's any bugfix, documentation improvement, or general enhancement you'd like to see in the repository! Please fully fill out all required fields in the most appropriate issue form. 14 | 15 | ## Sending Contributions 16 | 17 | Sending your own changes as contribution is always appreciated! 18 | There are two steps involved: 19 | 20 | 1. [Finding an Issue](#finding-an-issue) 21 | 2. [Sending a Pull Request](#sending-a-pull-request) 22 | 23 | ### Finding an Issue 24 | 25 | With the exception of very small typos, all changes to this repository generally need to correspond to an [open issue marked as `accepting prs` on the issue tracker](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aopen+is%3Aissue+label%3A%22accepting+prs%22). 26 | If this is your first time contributing, consider searching for [unassigned issues that also have the `good first issue` label](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aopen+is%3Aissue+label%3A%22accepting+prs%22+label%3A%22good+first+issue%22+no%3Aassignee). 27 | If the issue you'd like to fix isn't found on the issue, see [Reporting Issues](#reporting-issues) for filing your own (please do!). 28 | 29 | ### Sending a Pull Request 30 | 31 | Once you've identified an open issue accepting PRs that doesn't yet have a PR sent, you're free to send a pull request. 32 | Be sure to fill out the pull request template's requested information -- otherwise your PR will likely be closed. 33 | 34 | PRs are also expected to have a title that adheres to [commitlint](https://github.com/conventional-changelog/commitlint). 35 | Only PR titles need to be in that format, not individual commits. 36 | Don't worry if you get this wrong: you can always change the PR title after sending it. 37 | Check [previously merged PRs](https://github.com/borjapazr/express-typescript-skeleton/pulls?q=is%3Apr+is%3Amerged+-label%3Adependencies+) for reference. 38 | 39 | #### Draft PRs 40 | 41 | If you don't think your PR is ready for review, [set it as a draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request#converting-a-pull-request-to-a-draft). 42 | Draft PRs won't be reviewed. 43 | 44 | #### Granular PRs 45 | 46 | Please keep pull requests single-purpose: in other words, don't attempt to solve multiple unrelated problems in one pull request. 47 | Send one PR per area of concern. 48 | Multi-purpose pull requests are harder and slower to review, block all changes from being merged until the whole pull request is reviewed, and are difficult to name well with semantic PR titles. 49 | 50 | #### Pull Request Reviews 51 | 52 | When a PR is not in draft, it's considered ready for review. 53 | Please don't manually `@` tag anybody to request review. 54 | A maintainer will look at it when they're next able to. 55 | 56 | PRs should have passing [GitHub status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) before review is requested (unless there are explicit questions asked in the PR about any failures). 57 | 58 | #### Asking Questions 59 | 60 | If you need help and/or have a question, posting a comment in the PR is a great way to do so. 61 | There's no need to tag anybody individually. 62 | One of us will drop by and help when we can. 63 | 64 | Please post comments as [line comments](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#adding-line-comments-to-a-pull-request) when possible, so that they can be threaded. 65 | You can [resolve conversations](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#resolving-conversations) on your own when you feel they're resolved - no need to comment explicitly and/or wait for a maintainer. 66 | 67 | #### Requested Changes 68 | 69 | After a maintainer reviews your PR, they may request changes on it. 70 | Once you've made those changes, [re-request review on GitHub](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/about-pull-request-reviews#re-requesting-a-review). 71 | 72 | Please try not to force-push commits to PRs that have already been reviewed. 73 | Doing so makes it harder to review the changes. 74 | We squash merge all commits so there's no need to try to preserve Git history within a PR branch. 75 | 76 | Once you've addressed all our feedback by making code changes and/or started a followup discussion, [re-request review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/about-pull-request-reviews#re-requesting-a-review) from each maintainer whose feedback you addressed. 77 | 78 | Once all feedback is addressed and the PR is approved, we'll ensure the branch is up to date with `main` and merge it for you. 79 | 80 | #### Post-Merge Recognition 81 | 82 | Once your PR is merged, if you haven't yet been added to the [_Contributors_ table in the README.md](../README.md#contributors) for its [type of contribution](https://allcontributors.org/docs/en/emoji-key 'Allcontributors emoji key'), you should be soon. 83 | Please do ping the maintainer who merged your PR if that doesn't happen within 24 hours - it was likely an oversight on our end! 84 | -------------------------------------------------------------------------------- /.github/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Please, check [README.md](README.md) to get started. 4 | -------------------------------------------------------------------------------- /.github/FUNDING.md: -------------------------------------------------------------------------------- 1 | github: borjapazr 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ## Overview 8 | 9 | ... 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Bug Report Checklist 5 | options: 6 | - label: I have tried restarting my IDE and the issue persists. 7 | required: true 8 | - label: I have pulled the latest `main` branch of the repository. 9 | required: true 10 | - label: I have [searched for related issues](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aissue) and found none that matched my issue. 11 | required: true 12 | type: checkboxes 13 | - attributes: 14 | description: What did you expect to happen? 15 | label: Expected 16 | type: textarea 17 | validations: 18 | required: true 19 | - attributes: 20 | description: What happened instead? 21 | label: Actual 22 | type: textarea 23 | validations: 24 | required: true 25 | - attributes: 26 | description: Any additional info you'd like to provide. 27 | label: Additional Info 28 | type: textarea 29 | description: Report a bug trying to run the code 30 | labels: 31 | - 'type: bug' 32 | name: 🐛 Report a Bug 33 | title: '🐛 Bug: ' 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-documentation.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Bug Report Checklist 5 | options: 6 | - label: I have pulled the latest `main` branch of the repository. 7 | required: true 8 | - label: I have [searched for related issues](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aissue) and found none that matched my issue. 9 | required: true 10 | type: checkboxes 11 | - attributes: 12 | description: What would you like to report? 13 | label: Overview 14 | type: textarea 15 | validations: 16 | required: true 17 | - attributes: 18 | description: Any additional info you'd like to provide. 19 | label: Additional Info 20 | type: textarea 21 | description: Report a typo or missing area of documentation 22 | labels: 23 | - 'area: documentation' 24 | name: 📝 Documentation 25 | title: '📝 Documentation: ' 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03-feature.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Feature Request Checklist 5 | options: 6 | - label: I have pulled the latest `main` branch of the repository. 7 | required: true 8 | - label: I have [searched for related issues](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aissue) and found none that matched my issue. 9 | required: true 10 | type: checkboxes 11 | - attributes: 12 | description: What did you expect to be able to do? 13 | label: Overview 14 | type: textarea 15 | validations: 16 | required: true 17 | - attributes: 18 | description: Any additional info you'd like to provide. 19 | label: Additional Info 20 | type: textarea 21 | description: Request that a new feature be added or an existing feature improved 22 | labels: 23 | - 'type: feature' 24 | name: 🚀 Request a Feature 25 | title: '🚀 Feature: ' 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/04-tooling.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Bug Report Checklist 5 | options: 6 | - label: I have tried restarting my IDE and the issue persists. 7 | required: true 8 | - label: I have pulled the latest `main` branch of the repository. 9 | required: true 10 | - label: I have [searched for related issues](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aissue) and found none that matched my issue. 11 | required: true 12 | type: checkboxes 13 | - attributes: 14 | description: What did you expect to be able to do? 15 | label: Overview 16 | type: textarea 17 | validations: 18 | required: true 19 | - attributes: 20 | description: Any additional info you'd like to provide. 21 | label: Additional Info 22 | type: textarea 23 | description: Report a bug or request an enhancement in repository tooling 24 | labels: 25 | - 'area: tooling' 26 | name: 🛠 Tooling 27 | title: '🛠 Tooling: ' 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## PR Checklist 6 | 7 | - [ ] Addresses an existing open issue: fixes #000 8 | - [ ] That issue was marked as [`status: accepting prs`](https://github.com/borjapazr/express-typescript-skeleton/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) 9 | - [ ] Steps in [CONTRIBUTING.md](https://github.com/borjapazr/express-typescript-skeleton/blob/main/.github/CONTRIBUTING.md) were taken 10 | 11 | ## Overview 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | We take all security vulnerabilities seriously. 4 | If you have a vulnerability or other security issues to disclose: 5 | 6 | - Thank you very much, please do! 7 | - Please send them to us by emailing `borjapazr@gmail.com` 8 | 9 | We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. 10 | -------------------------------------------------------------------------------- /.github/actions/setup-environment/action.yml: -------------------------------------------------------------------------------- 1 | name: setup-environment 2 | description: Composite action to setup environment and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: 🤹‍♂️ Install asdf 8 | uses: asdf-vm/actions/setup@v3 9 | 10 | - name: 🗂️ Cache asdf 11 | id: cache-asdf 12 | uses: actions/cache@v4 13 | env: 14 | cache-name: cache-asdf 15 | cache-path: ~/.asdf 16 | with: 17 | path: ${{ env.cache-path }} 18 | key: ${{ runner.os }}-se-${{ env.cache-name }}-${{ hashFiles('**/.tool-versions') }} 19 | restore-keys: | 20 | ${{ runner.os }}-se-${{ env.cache-name }}- 21 | 22 | - name: 🛠️ Install tools from .tool-versions 23 | if: ${{ steps.cache-asdf.outputs.cache-hit != 'true' }} 24 | continue-on-error: true 25 | uses: asdf-vm/actions/install@v3 26 | 27 | - name: 🗂️ Cache node_modules 28 | id: cache-node-modules 29 | uses: actions/cache@v4 30 | env: 31 | cache-name: cache-node-modules 32 | cache-path: node_modules 33 | with: 34 | path: ${{ env.cache-path }} 35 | key: ${{ runner.os }}-se-${{ env.cache-name }}-${{ hashFiles('**/.tool-versions') }}-${{ hashFiles('**/package-lock.json') }} 36 | 37 | - name: 📥 Install dependencies 38 | if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' }} 39 | shell: bash 40 | run: npm ci 41 | 42 | - name: 💎 Generate Prisma models 43 | shell: bash 44 | run: npm run prisma:generate 45 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "timezone": "Europe/Madrid", 4 | "schedule": ["on friday"], 5 | "extends": ["config:base"], 6 | "prHeader": "Express TypeScript Skeleton", 7 | "assignees": ["borjapazr"], 8 | "labels": ["chore:update", "dependencies"], 9 | "ignoreDeps": ["read-pkg"] 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: 🐙 Generate new release 14 | runs-on: ubuntu-latest 15 | if: startsWith(github.ref, 'refs/tags/') 16 | 17 | steps: 18 | - name: ⬇️ Checkout project 19 | uses: actions/checkout@v4 20 | 21 | - name: 📋 Build Changelog 22 | run: npx extract-changelog-release > RELEASE_BODY.md 23 | 24 | - name: 🍻 Build and generate new release 25 | uses: softprops/action-gh-release@v1 26 | with: 27 | body_path: RELEASE_BODY.md 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | distribute: 31 | name: 🛩️ Deliver project 32 | runs-on: ubuntu-latest 33 | needs: release 34 | 35 | steps: 36 | - name: ⬇️ Checkout project 37 | uses: actions/checkout@v4 38 | 39 | - name: 💻 Log in to Docker Hub 40 | uses: docker/login-action@v3 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | 45 | - name: 🏷️ Extract metadata (tags, labels) for Docker 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: borjapazr/express-typescript-skeleton 50 | 51 | - name: 💽 Build and push Docker image 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | file: docker/Dockerfile 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | 60 | redeploy: 61 | name: 🔄 Redeploy webhook call 62 | runs-on: ubuntu-latest 63 | needs: distribute 64 | 65 | steps: 66 | - name: 🚀 Deploy Express Typescript Skeleton webhook 67 | uses: joelwmale/webhook-action@master 68 | with: 69 | url: ${{ secrets.REDEPLOY_WEBHOOK_URL }} 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ci-${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | validate: 15 | name: ✅ Validate project 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: ⬇️ Checkout project 20 | uses: actions/checkout@v4 21 | 22 | - name: 🧙‍♂️ Setup environment 23 | uses: ./.github/actions/setup-environment 24 | 25 | - name: 🖍️ Check types 26 | run: npm run check:types 27 | 28 | - name: 💅 Check format 29 | run: npm run check:format 30 | 31 | - name: 📑 Check lint 32 | run: npm run check:lint 33 | 34 | - name: 📦 Check package.json 35 | run: npm run check:packagejson 36 | 37 | - name: 📝 Check markdown 38 | run: npm run check:markdown 39 | 40 | - name: 🔤 Check spelling 41 | run: npm run check:spelling 42 | 43 | test: 44 | name: 🧑‍🔬 Test project 45 | runs-on: ubuntu-latest 46 | needs: validate 47 | 48 | steps: 49 | - name: ⬇️ Checkout project 50 | uses: actions/checkout@v4 51 | 52 | - name: 🧙‍♂️ Setup environment 53 | uses: ./.github/actions/setup-environment 54 | 55 | - name: 🧪 Run tests 56 | run: npm run test:coverage 57 | 58 | build: 59 | name: 🧰 Build project 60 | runs-on: ubuntu-latest 61 | needs: test 62 | 63 | steps: 64 | - name: ⬇️ Checkout project 65 | uses: actions/checkout@v4 66 | 67 | - name: 🧙‍♂️ Setup environment 68 | uses: ./.github/actions/setup-environment 69 | 70 | - name: ⚒️ Build project 71 | run: npm run build 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | .npm 6 | 7 | # Testing 8 | coverage 9 | 10 | # Production 11 | dist 12 | 13 | # Misc 14 | .DS_Store 15 | .AppleDouble 16 | .LSOverride 17 | 18 | # Logging 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | *.log 23 | logs 24 | 25 | # IDE 26 | .idea 27 | 28 | # Runtime data 29 | pids 30 | *.pid 31 | *.seed 32 | 33 | # Lint 34 | .eslintcache 35 | 36 | # App data 37 | .docker 38 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # The reason we're exporting this variable is because of this issue: 5 | # https://github.com/typicode/husky/issues/968 6 | export FORCE_COLOR=1 7 | 8 | npx commitlint --edit $1 || 9 | ( 10 | echo '✍📤 It seems that the format of the commit does not follow the conventional commit convention. You can also try committing with the "npm run commit" command.'; 11 | false; 12 | ) 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # The reason we're exporting this variable is because of this issue: 5 | # https://github.com/typicode/husky/issues/968 6 | export FORCE_COLOR=1 7 | 8 | echo '🔍🎨 Formating and checking staged files before committing!' 9 | 10 | npx lint-staged || 11 | ( 12 | echo '💀❌ Ooops! Formating and checking process has failed!'; 13 | false; 14 | ) 15 | 16 | echo '🥳✅ Formating and checking process has been successfully completed!' 17 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,ts}': [ 3 | 'prettier --check --write --ignore-unknown', 4 | 'eslint --cache --color --fix', 5 | () => 'tsc --pretty --noEmit' 6 | ], 7 | '!*.{js,ts}': ['prettier --check --write --ignore-unknown'], 8 | '*.{md,mdx}': ['markdownlint'], 9 | '{LICENSE,README.md,TODO.md,.github/**/*.md,src/**/*.ts}': ['cspell --gitignore'], 10 | 'package.json': ['npmPkgJsonLint'] 11 | }; 12 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "markdownlint/style/prettier", 3 | "first-line-h1": false, 4 | "no-inline-html": false 5 | } 6 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | .github/CODE_OF_CONDUCT.md 2 | CHANGELOG.md 3 | lib/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | upgrade: true, 3 | reject: ['read-pkg'] 4 | }; 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .commitlintrc* 2 | .docker 3 | .DS_Store 4 | .editorconfig 5 | .eslintcache 6 | .eslintignore 7 | .eslintrc* 8 | .nycrc 9 | .prettierrc* 10 | .travis.yml 11 | *.log 12 | coverage 13 | logs 14 | node_modules 15 | README.md 16 | test 17 | -------------------------------------------------------------------------------- /.npmpackagejsonlintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borjapazr/express-typescript-skeleton/815c831fe6c125225e0a93b2375d767c9f860226/.npmpackagejsonlintignore -------------------------------------------------------------------------------- /.npmpackagejsonlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "npm-package-json-lint-config-default", 3 | "rules": { "require-description": "error", "require-license": "error" } 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .docker 2 | coverage 3 | dist 4 | logs 5 | node_modules 6 | package-lock.json 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | endOfLine: 'lf', 3 | printWidth: 120, 4 | tabWidth: 2, 5 | singleQuote: true, 6 | trailingComma: 'none', 7 | semi: true, 8 | quoteProps: 'as-needed', 9 | arrowParens: 'avoid', 10 | plugins: ['prettier-plugin-packagejson', 'prettier-plugin-prisma'], 11 | overrides: [ 12 | { 13 | files: '*.ts', 14 | options: { parser: 'typescript' } 15 | } 16 | ] 17 | }; 18 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.11.1 2 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | header: '# Changelog\n\nAll notable changes to this project will be documented in this file.\n', 3 | types: [ 4 | { type: 'chore', section: 'Others', hidden: false }, 5 | { type: 'revert', section: 'Reverts', hidden: false }, 6 | { type: 'feat', section: 'Features', hidden: false }, 7 | { type: 'fix', section: 'Bug Fixes', hidden: false }, 8 | { type: 'improvement', section: 'Feature Improvements', hidden: false }, 9 | { type: 'docs', section: 'Docs', hidden: false }, 10 | { type: 'style', section: 'Styling', hidden: false }, 11 | { type: 'refactor', section: 'Code Refactoring', hidden: false }, 12 | { type: 'perf', section: 'Performance Improvements', hidden: false }, 13 | { type: 'test', section: 'Tests', hidden: false }, 14 | { type: 'build', section: 'Build System', hidden: false }, 15 | { type: 'ci', section: 'CI', hidden: false } 16 | ], 17 | scripts: { 18 | postchangelog: 'prettier -w CHANGELOG.md' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "DavidAnson.vscode-markdownlint", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "eamodio.gitlens", 7 | "streetsidesoftware.code-spell-checker", 8 | "firsttris.vscode-jest-runner", 9 | "Orta.vscode-jest" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "command": "npm run dev", 5 | "name": "development", 6 | "request": "launch", 7 | "type": "node-terminal" 8 | }, 9 | { 10 | "command": "npm run test", 11 | "name": "testing", 12 | "request": "launch", 13 | "type": "node-terminal" 14 | }, 15 | { 16 | "address": "localhost", 17 | "localRoot": "${workspaceFolder}", 18 | "name": "docker", 19 | "port": 9229, 20 | "remoteRoot": "/code/app", 21 | "request": "attach", 22 | "type": "node" 23 | } 24 | ], 25 | "version": "0.2.0" 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[prisma]": { 3 | "editor.defaultFormatter": "Prisma.prisma" 4 | }, 5 | "cSpell.enabled": true, 6 | "cSpell.userWords": [], // only use words from .cspell.json 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.fixAll.format": "explicit", 10 | "source.fixAll.stylelint": "explicit", 11 | "source.fixAll.htmlhint": "explicit" 12 | }, 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.formatOnPaste": true, 15 | "editor.formatOnSave": true, 16 | "eslint.alwaysShowStatus": true, 17 | "eslint.options": { 18 | "extensions": [".js", ".ts", ".html"] 19 | }, 20 | "eslint.validate": ["javascript", "typescript", "html"], 21 | "htmlhint.enable": true, 22 | "css.validate": false, 23 | "less.validate": false, 24 | "scss.validate": false, 25 | "errorLens.gutterIconsEnabled": true, 26 | "errorLens.gutterIconSet": "borderless", 27 | "errorLens.followCursor": "activeLine", 28 | "stylelint.enable": true, 29 | "stylelint.validate": ["css", "scss"], 30 | "editor.acceptSuggestionOnEnter": "on" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Borja Paz Rodríguez borjapazr@gmail.com 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## Include .env file 2 | include .env 3 | 4 | ## Root directory 5 | ROOT_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) 6 | 7 | ## Set 'bash' as default shell 8 | SHELL := $(shell which bash) 9 | 10 | ## Set 'help' target as the default goal 11 | .DEFAULT_GOAL := help 12 | 13 | ## Test if the dependencies we need to run this Makefile are installed 14 | DOCKER := DOCKER_BUILDKIT=1 $(shell command -v docker) 15 | DOCKER_COMPOSE := COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 $(shell command -v docker-compose) 16 | DOCKER_COMPOSE_FILE := $(ROOT_DIR)/docker/docker-compose.yml 17 | NPM := $(shell command -v npm) 18 | 19 | .PHONY: help 20 | help: ## Show this help 21 | @egrep -h '^[a-zA-Z0-9_\/-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort -d | awk 'BEGIN {FS = ":.*?## "; printf "Usage: make \033[0;34mTARGET\033[0m \033[0;35m[ARGUMENTS]\033[0m\n\n"; printf "Targets:\n"}; {printf " \033[33m%-25s\033[0m \033[0;32m%s\033[0m\n", $$1, $$2}' 22 | 23 | .PHONY: requirements 24 | requirements: ## Check if the requirements are satisfied 25 | ifndef DOCKER 26 | @echo "🐳 Docker is not available. Please install docker." 27 | @exit 1 28 | endif 29 | ifndef DOCKER_COMPOSE 30 | @echo "🐳🧩 docker-compose is not available. Please install docker-compose." 31 | @exit 1 32 | endif 33 | ifndef NPM 34 | @echo "📦🧩 npm is not available. Please install npm." 35 | @exit 1 36 | endif 37 | @echo "🆗 The necessary dependencies are already installed!" 38 | 39 | TAG ?= prod 40 | 41 | .PHONY: install 42 | install: requirements ## Install the project 43 | @echo "🍿 Installing dependencies..." 44 | @npm install 45 | @npm run prisma:generate 46 | 47 | .PHONY: start 48 | start: install ## Start application in development mode 49 | @echo "▶️ Starting app in development mode..." 50 | @npm run dev 51 | 52 | .PHONY: start/docker/db 53 | start/docker/db: ## Start database container 54 | @echo "▶️ Starting database (Docker)..." 55 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env up -d express-typescript-skeleton-postgres express-typescript-skeleton-pgweb 56 | 57 | .PHONY: stop/docker/db 58 | stop/docker/db: ## Stop database container 59 | @echo "🛑 Stopping database (Docker)..." 60 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env stop express-typescript-skeleton-postgres express-typescript-skeleton-pgweb 61 | 62 | .PHONY: start/cache 63 | start/docker/cache: ## Start cache container 64 | @echo "▶️ Starting cache (Docker)..." 65 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env up -d express-typescript-skeleton-redis express-typescript-skeleton-redis-commander 66 | 67 | .PHONY: stop/cache 68 | stop/docker/cache: ## Stop cache container 69 | @echo "🛑 Stopping cache (Docker)..." 70 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env stop express-typescript-skeleton-redis express-typescript-skeleton-redis-commander 71 | 72 | .PHONY: start/docker 73 | start/docker: ## Start application in a Docker container 74 | @echo "▶️ Starting app in production mode (Docker)..." 75 | @mkdir -p -m 755 ${LOGS_VOLUME} 76 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env up -d --build 77 | 78 | .PHONY: stop/docker 79 | stop/docker: ## Stop application running in a Docker container 80 | @echo "🛑 Stopping app..." 81 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env down 82 | 83 | .PHONY: clean/docker 84 | clean/docker: ## Clean all container resources 85 | @echo "🧼 Cleaning all resources..." 86 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env down --rmi local --volumes --remove-orphans 87 | 88 | .PHONY: build/docker 89 | build/docker: ## Build Docker image of the application 90 | @echo "📦 Building project Docker image..." 91 | @docker build --build-arg PORT=$(PORT) -t $(APP_NAME):$(TAG) -f ./docker/Dockerfile . 92 | 93 | .PHONY: logs 94 | logs: ## Show logs for all or c= containers 95 | @$(DOCKER_COMPOSE) -f $(DOCKER_COMPOSE_FILE) --env-file .env logs --tail=100 -f $(c) 96 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.6-labs 2 | 3 | ARG NODE_VERSION=20.11.1 4 | 5 | FROM node:${NODE_VERSION}-alpine as base 6 | 7 | LABEL maintainer="Borja Paz Rodríguez " \ 8 | version="2.7.1" \ 9 | description="🔰🦸 Template to start developing a REST API with Node.js (Express), TypeScript, ESLint, Prettier, Husky, Prisma, etc." \ 10 | license="MIT" \ 11 | org.label-schema.name="express-typescript-skeleton" \ 12 | org.label-schema.description="🔰🦸 Template to start developing a REST API with Node.js (Express), TypeScript, ESLint, Prettier, Husky, Prisma, etc." \ 13 | org.label-schema.url="https://bpaz.dev" \ 14 | org.label-schema.vcs-url="https://github.com/borjapazr/express-typescript-skeleton" \ 15 | org.label-schema.version="2.7.1" \ 16 | org.label-schema.schema-version="1.0" 17 | 18 | ARG WAIT_VERSION=2.12.1 19 | 20 | RUN <'], 12 | testEnvironment: 'node', 13 | testEnvironmentOptions: { 14 | NODE_ENV: 'test' 15 | }, 16 | testMatch: ['/test/**/?(*.)+(unit|int|e2e|spec|test).(ts|js)'], 17 | preset: 'ts-jest', 18 | transform: { 19 | '^.+.tsx?$': [ 20 | 'ts-jest', 21 | { 22 | tsconfig: '/tsconfig.json' 23 | } 24 | ] 25 | }, 26 | // Resolve 'paths' from tsconfig.json 27 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '' }), 28 | // Ignore paths and modules 29 | modulePathIgnorePatterns: ['/dist'], 30 | 31 | /* Bootstrap settings */ 32 | // Set initial config and enable jest-extended features 33 | setupFilesAfterEnv: ['/test/jest.setup.ts', 'jest-extended/all'], 34 | 35 | /* Global test settings */ 36 | // Automatically clear mock calls and instances between every test 37 | clearMocks: true, 38 | 39 | /* Coverage settings */ 40 | collectCoverage: false, 41 | // The directory where Jest should output its coverage files 42 | coverageDirectory: 'coverage', 43 | // An array of glob patterns indicating a set of files for which coverage information should be collected 44 | collectCoverageFrom: ['/src/**/*.ts'], 45 | coveragePathIgnorePatterns: [ 46 | '/node_modules', 47 | '/src/types', 48 | '/src/index.ts', 49 | '/src/healthcheck.ts' 50 | ], 51 | // Jest custom reporters 52 | reporters: ['default'] 53 | /* 54 | * Uncomment if you want to set thresholds for code coverage 55 | coverageThreshold: { 56 | global: { 57 | branches: 100, 58 | functions: 100, 59 | lines: 100, 60 | statements: 100 61 | } 62 | } 63 | */ 64 | }; 65 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register -r tsconfig-paths/register ./src/index.ts", 3 | "ext": "ts,json,yml", 4 | "ignore": ["dist", "node_modules"], 5 | "watch": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /src/application/health/check-health-status.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | 4 | class CheckHealthStatusRequest extends UseCaseRequest { 5 | readonly appVersion: string; 6 | 7 | constructor(triggeredBy: TriggeredBy, appVersion: string) { 8 | super(triggeredBy); 9 | this.appVersion = appVersion; 10 | } 11 | 12 | public static create(triggeredBy: TriggeredBy, appVersion: string): CheckHealthStatusRequest { 13 | return new CheckHealthStatusRequest(triggeredBy, appVersion); 14 | } 15 | 16 | protected validatePayload(): void { 17 | // no validation needed 18 | } 19 | } 20 | 21 | export { CheckHealthStatusRequest }; 22 | -------------------------------------------------------------------------------- /src/application/health/check-health-status.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { HealthStatus } from '@domain/health'; 3 | 4 | import { CheckHealthStatusRequest } from './check-health-status.request'; 5 | import { HealthStatusResponse } from './health-status.response'; 6 | 7 | @UseCase() 8 | class CheckHealthStatusUseCase extends BaseUseCase { 9 | protected performOperation(request: CheckHealthStatusRequest): Promise { 10 | return Promise.resolve( 11 | HealthStatusResponse.fromDomainModel( 12 | HealthStatus.create('ALIVE', '🚀 To infinity and beyond!', request.appVersion) 13 | ) 14 | ); 15 | } 16 | } 17 | 18 | export { CheckHealthStatusUseCase }; 19 | -------------------------------------------------------------------------------- /src/application/health/health-status.response.ts: -------------------------------------------------------------------------------- 1 | import { HealthStatus } from '@domain/health'; 2 | 3 | class HealthStatusResponse { 4 | readonly status: string; 5 | 6 | readonly message: string; 7 | 8 | readonly appVersion: string; 9 | 10 | constructor(status: string, message: string, appVersion: string) { 11 | this.status = status; 12 | this.message = message; 13 | this.appVersion = appVersion; 14 | } 15 | 16 | public static create(status: string, message: string, appVersion: string): HealthStatusResponse { 17 | return new HealthStatusResponse(status, message, appVersion); 18 | } 19 | 20 | public static fromDomainModel(healthStatus: HealthStatus): HealthStatusResponse { 21 | return new HealthStatusResponse(healthStatus.status, healthStatus.message, healthStatus.appVersion); 22 | } 23 | } 24 | 25 | export { HealthStatusResponse }; 26 | -------------------------------------------------------------------------------- /src/application/health/index.ts: -------------------------------------------------------------------------------- 1 | export * from './check-health-status.request'; 2 | export * from './check-health-status.usecase'; 3 | export * from './health-status.response'; 4 | -------------------------------------------------------------------------------- /src/application/sessions/end/end-session.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { Nullable } from '@domain/shared'; 3 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 4 | 5 | class EndSessionRequest extends UseCaseRequest { 6 | readonly accessToken: Nullable; 7 | 8 | readonly refreshToken: Nullable; 9 | 10 | constructor(triggeredBy: TriggeredBy, accessToken: Nullable, refreshToken: Nullable) { 11 | super(triggeredBy); 12 | this.accessToken = accessToken; 13 | this.refreshToken = refreshToken; 14 | } 15 | 16 | public static create( 17 | triggeredBy: TriggeredBy, 18 | accessToken: Nullable, 19 | refreshToken: Nullable 20 | ): EndSessionRequest { 21 | return new EndSessionRequest(triggeredBy, accessToken, refreshToken); 22 | } 23 | 24 | protected validatePayload(): void { 25 | // no validation needed 26 | } 27 | } 28 | 29 | export { EndSessionRequest }; 30 | -------------------------------------------------------------------------------- /src/application/sessions/end/end-session.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { 3 | InvalidSessionException, 4 | Session, 5 | SessionRepository, 6 | SessionRevocationReason, 7 | SessionRevokedBy 8 | } from '@domain/sessions'; 9 | import { TokenProviderDomainService } from '@domain/sessions/tokens'; 10 | import { Nullable } from '@domain/shared'; 11 | 12 | import { EndSessionRequest } from './end-session.request'; 13 | 14 | @UseCase() 15 | class EndSessionUseCase extends BaseUseCase { 16 | private tokenProviderDomainService: TokenProviderDomainService; 17 | 18 | private sessionRepository: SessionRepository; 19 | 20 | constructor(tokenProviderDomainService: TokenProviderDomainService, sessionRepository: SessionRepository) { 21 | super(); 22 | this.tokenProviderDomainService = tokenProviderDomainService; 23 | this.sessionRepository = sessionRepository; 24 | } 25 | 26 | protected async performOperation({ 27 | accessToken: accessTokenString, 28 | refreshToken: refreshTokenString 29 | }: EndSessionRequest): Promise { 30 | const session = await this.getAndValidateSession(accessTokenString, refreshTokenString); 31 | 32 | session.revoke(new SessionRevokedBy(session.userData.username), new SessionRevocationReason('logout')); 33 | 34 | await this.sessionRepository.update(session); 35 | } 36 | 37 | private async getAndValidateSession( 38 | accessTokenString: Nullable, 39 | refreshTokenString: Nullable 40 | ): Promise { 41 | let sessionUuid = null; 42 | 43 | if (accessTokenString) { 44 | sessionUuid = this.tokenProviderDomainService.parseAccessToken(accessTokenString)?.sessionUuid; 45 | } 46 | 47 | if (refreshTokenString) { 48 | sessionUuid = this.tokenProviderDomainService.parseRefreshToken(refreshTokenString)?.sessionUuid; 49 | } 50 | 51 | if (sessionUuid != null) { 52 | const session = await this.sessionRepository.findByUuid(sessionUuid); 53 | 54 | if (session != null) { 55 | return session; 56 | } 57 | } 58 | 59 | throw new InvalidSessionException(); 60 | } 61 | } 62 | 63 | export { EndSessionUseCase }; 64 | -------------------------------------------------------------------------------- /src/application/sessions/end/index.ts: -------------------------------------------------------------------------------- 1 | export * from './end-session.request'; 2 | export * from './end-session.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/sessions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session.response'; 2 | -------------------------------------------------------------------------------- /src/application/sessions/refresh/index.ts: -------------------------------------------------------------------------------- 1 | export * from './refresh-session.request'; 2 | export * from './refresh-session.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/sessions/refresh/refresh-session.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | class RefreshSessionRequest extends UseCaseRequest { 6 | readonly refreshToken: string; 7 | 8 | constructor(triggeredBy: TriggeredBy, refreshToken: string) { 9 | super(triggeredBy); 10 | this.refreshToken = refreshToken; 11 | } 12 | 13 | public static create(triggeredBy: TriggeredBy, refreshToken: string): RefreshSessionRequest { 14 | return new RefreshSessionRequest(triggeredBy, refreshToken); 15 | } 16 | 17 | protected validatePayload(): void { 18 | if (this.refreshToken == null) { 19 | throw new InvalidParameterException('Refresh Token must be provided'); 20 | } 21 | } 22 | } 23 | 24 | export { RefreshSessionRequest }; 25 | -------------------------------------------------------------------------------- /src/application/sessions/refresh/refresh-session.usecase.ts: -------------------------------------------------------------------------------- 1 | import { SessionResponse } from '@application/sessions'; 2 | import { BaseUseCase, UseCase } from '@application/shared'; 3 | import { 4 | InvalidSessionException, 5 | Session, 6 | SessionExpiresAt, 7 | SessionRefreshTokenHash, 8 | SessionRepository, 9 | SessionUserData 10 | } from '@domain/sessions'; 11 | import { RefreshToken, TokenProviderDomainService } from '@domain/sessions/tokens'; 12 | import { User, UserRepository, UserUuid } from '@domain/users'; 13 | 14 | import { RefreshSessionRequest } from './refresh-session.request'; 15 | 16 | @UseCase() 17 | class RefreshSessionUseCase extends BaseUseCase { 18 | private tokenProviderDomainService: TokenProviderDomainService; 19 | 20 | private userRepository: UserRepository; 21 | 22 | private sessionRepository: SessionRepository; 23 | 24 | constructor( 25 | tokenProviderDomainService: TokenProviderDomainService, 26 | userRepository: UserRepository, 27 | sessionRepository: SessionRepository 28 | ) { 29 | super(); 30 | this.tokenProviderDomainService = tokenProviderDomainService; 31 | this.userRepository = userRepository; 32 | this.sessionRepository = sessionRepository; 33 | } 34 | 35 | protected async performOperation({ 36 | refreshToken: refreshTokenString 37 | }: RefreshSessionRequest): Promise { 38 | const refreshToken = this.getAndValidateRefreshToken(refreshTokenString); 39 | 40 | const session = await this.getAndValidateSession(refreshToken); 41 | 42 | const user = await this.getAndValidateUser(refreshToken.userUuid); 43 | 44 | const newAccessToken = this.tokenProviderDomainService.createAccessToken( 45 | session.uuid, 46 | user.uuid, 47 | user.username, 48 | user.email, 49 | user.roles 50 | ); 51 | 52 | const newRefreshToken = this.tokenProviderDomainService.createRefreshToken( 53 | session.uuid, 54 | user.uuid, 55 | user.username, 56 | user.email, 57 | user.roles 58 | ); 59 | 60 | session.refreshTokenHash = await SessionRefreshTokenHash.createFromPlainRefreshToken(newRefreshToken.value); 61 | session.expiresAt = new SessionExpiresAt(refreshToken.expiresAt.value); 62 | session.userData = new SessionUserData( 63 | user.username.value, 64 | user.email.value, 65 | user.roles.map(role => role.value) 66 | ); 67 | 68 | this.sessionRepository.update(session); 69 | 70 | return SessionResponse.create(session, newAccessToken, newRefreshToken); 71 | } 72 | 73 | private getAndValidateRefreshToken(refreshTokenString: string): RefreshToken { 74 | const refreshToken = this.tokenProviderDomainService.parseRefreshToken(refreshTokenString); 75 | 76 | if (refreshToken == null) { 77 | throw new InvalidSessionException(); 78 | } 79 | 80 | return refreshToken; 81 | } 82 | 83 | private async getAndValidateSession(refreshToken: RefreshToken): Promise { 84 | const session = await this.sessionRepository.findByUuid(refreshToken.sessionUuid); 85 | 86 | if (session == null || !(await session.refreshTokenMatches(refreshToken.value)) || !session.isActive()) { 87 | throw new InvalidSessionException(); 88 | } 89 | 90 | return session; 91 | } 92 | 93 | private async getAndValidateUser(uuid: UserUuid): Promise { 94 | const user = await this.userRepository.findByUuid(uuid); 95 | 96 | if (user == null) { 97 | throw new InvalidSessionException(); 98 | } 99 | 100 | return user; 101 | } 102 | } 103 | 104 | export { RefreshSessionUseCase }; 105 | -------------------------------------------------------------------------------- /src/application/sessions/session.response.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@domain/sessions/session'; 2 | import { AccessToken, RefreshToken } from '@domain/sessions/tokens'; 3 | 4 | class SessionResponse { 5 | readonly session: Session; 6 | 7 | readonly accessToken: AccessToken; 8 | 9 | readonly refreshToken: RefreshToken; 10 | 11 | constructor(session: Session, accessToken: AccessToken, refreshToken: RefreshToken) { 12 | this.session = session; 13 | this.accessToken = accessToken; 14 | this.refreshToken = refreshToken; 15 | } 16 | 17 | public static create(session: Session, accessToken: AccessToken, refreshToken: RefreshToken): SessionResponse { 18 | return new SessionResponse(session, accessToken, refreshToken); 19 | } 20 | } 21 | 22 | export { SessionResponse }; 23 | -------------------------------------------------------------------------------- /src/application/sessions/start/index.ts: -------------------------------------------------------------------------------- 1 | export * from './start-session.request'; 2 | export * from './start-session.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/sessions/start/start-session.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | class StartSessionRequest extends UseCaseRequest { 6 | readonly userUuid: string; 7 | 8 | constructor(triggeredBy: TriggeredBy, userUuid: string) { 9 | super(triggeredBy); 10 | this.userUuid = userUuid; 11 | } 12 | 13 | public static create(triggeredBy: TriggeredBy, userUuid: string): StartSessionRequest { 14 | return new StartSessionRequest(triggeredBy, userUuid); 15 | } 16 | 17 | protected validatePayload(): void { 18 | if (this.userUuid == null) { 19 | throw new InvalidParameterException('User UUID must be provided'); 20 | } 21 | } 22 | } 23 | 24 | export { StartSessionRequest }; 25 | -------------------------------------------------------------------------------- /src/application/sessions/start/start-session.usecase.ts: -------------------------------------------------------------------------------- 1 | import { SessionResponse } from '@application/sessions'; 2 | import { BaseUseCase, UseCase } from '@application/shared'; 3 | import { 4 | Session, 5 | SessionExpiresAt, 6 | SessionRefreshTokenHash, 7 | SessionRepository, 8 | SessionUserData 9 | } from '@domain/sessions'; 10 | import { TokenProviderDomainService } from '@domain/sessions/tokens'; 11 | import { Uuid } from '@domain/shared/value-object'; 12 | import { User, UserNotExistsException, UserRepository, UserUuid } from '@domain/users'; 13 | 14 | import { StartSessionRequest } from './start-session.request'; 15 | 16 | @UseCase() 17 | class StartSessionUseCase extends BaseUseCase { 18 | private tokenProviderDomainService: TokenProviderDomainService; 19 | 20 | private userRepository: UserRepository; 21 | 22 | private sessionRepository: SessionRepository; 23 | 24 | constructor( 25 | tokenProviderDomainService: TokenProviderDomainService, 26 | userRepository: UserRepository, 27 | sessionRepository: SessionRepository 28 | ) { 29 | super(); 30 | this.tokenProviderDomainService = tokenProviderDomainService; 31 | this.userRepository = userRepository; 32 | this.sessionRepository = sessionRepository; 33 | } 34 | 35 | protected async performOperation({ userUuid: userUuidString }: StartSessionRequest): Promise { 36 | const { uuid: userUuid, username, email, roles } = await this.getAndValidateUser(userUuidString); 37 | 38 | const sessionUuid = Uuid.random(); 39 | 40 | const accessToken = this.tokenProviderDomainService.createAccessToken( 41 | sessionUuid, 42 | userUuid, 43 | username, 44 | email, 45 | roles 46 | ); 47 | 48 | const refreshToken = this.tokenProviderDomainService.createRefreshToken( 49 | sessionUuid, 50 | userUuid, 51 | username, 52 | email, 53 | roles 54 | ); 55 | 56 | const session = Session.create( 57 | sessionUuid, 58 | userUuid, 59 | await SessionRefreshTokenHash.createFromPlainRefreshToken(refreshToken.value), 60 | new SessionUserData( 61 | username.value, 62 | email.value, 63 | roles.map(role => role.value) 64 | ), 65 | new SessionExpiresAt(refreshToken.expiresAt.value) 66 | ); 67 | 68 | const createdSession = await this.sessionRepository.create(session); 69 | 70 | return SessionResponse.create(createdSession, accessToken, refreshToken); 71 | } 72 | 73 | private async getAndValidateUser(uuid: string): Promise { 74 | const user = await this.userRepository.findByUuid(new UserUuid(uuid)); 75 | 76 | if (user == null) { 77 | throw new UserNotExistsException(uuid); 78 | } 79 | 80 | return user; 81 | } 82 | } 83 | 84 | export { StartSessionUseCase }; 85 | -------------------------------------------------------------------------------- /src/application/sessions/validate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validate-session.request'; 2 | export * from './validate-session.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/sessions/validate/validate-session.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { Nullable } from '@domain/shared'; 3 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 4 | 5 | class ValidateSessionRequest extends UseCaseRequest { 6 | readonly accessToken: Nullable; 7 | 8 | readonly refreshToken: Nullable; 9 | 10 | constructor(triggeredBy: TriggeredBy, accessToken: Nullable, refreshToken: Nullable) { 11 | super(triggeredBy); 12 | this.accessToken = accessToken; 13 | this.refreshToken = refreshToken; 14 | } 15 | 16 | public static create( 17 | triggeredBy: TriggeredBy, 18 | accessToken: Nullable, 19 | refreshToken: Nullable 20 | ): ValidateSessionRequest { 21 | return new ValidateSessionRequest(triggeredBy, accessToken, refreshToken); 22 | } 23 | 24 | protected validatePayload(): void { 25 | // no validation needed 26 | } 27 | } 28 | 29 | export { ValidateSessionRequest }; 30 | -------------------------------------------------------------------------------- /src/application/sessions/validate/validate-session.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { 3 | InvalidSessionException, 4 | SessionExpiresAt, 5 | SessionRefreshTokenHash, 6 | SessionRepository, 7 | SessionUserData 8 | } from '@domain/sessions'; 9 | import { TokenProviderDomainService } from '@domain/sessions/tokens'; 10 | import { UserRepository } from '@domain/users'; 11 | 12 | import { ValidateSessionRequest } from './validate-session.request'; 13 | import { ValidatedSessionResponse } from './validated-session.response'; 14 | 15 | @UseCase() 16 | class ValidateSessionUseCase extends BaseUseCase { 17 | private tokenProviderDomainService: TokenProviderDomainService; 18 | 19 | private userRepository: UserRepository; 20 | 21 | private sessionRepository: SessionRepository; 22 | 23 | constructor( 24 | tokenProviderDomainService: TokenProviderDomainService, 25 | userRepository: UserRepository, 26 | sessionRepository: SessionRepository 27 | ) { 28 | super(); 29 | this.tokenProviderDomainService = tokenProviderDomainService; 30 | this.userRepository = userRepository; 31 | this.sessionRepository = sessionRepository; 32 | } 33 | 34 | protected async performOperation({ 35 | accessToken: accessTokenString, 36 | refreshToken: refreshTokenString 37 | }: ValidateSessionRequest): Promise { 38 | const accessToken = accessTokenString ? this.tokenProviderDomainService.parseAccessToken(accessTokenString) : null; 39 | 40 | const refreshToken = refreshTokenString 41 | ? this.tokenProviderDomainService.parseRefreshToken(refreshTokenString) 42 | : null; 43 | 44 | if (accessToken != null && !accessToken.isExpired()) { 45 | const session = await this.sessionRepository.findByUuid(accessToken.sessionUuid); 46 | 47 | if (session != null && session.isActive()) { 48 | return ValidatedSessionResponse.createValidatedSession(session, accessToken, refreshToken); 49 | } 50 | } 51 | 52 | if (refreshToken != null && !refreshToken.isExpired()) { 53 | const session = await this.sessionRepository.findByUuid(refreshToken.sessionUuid); 54 | 55 | const user = await this.userRepository.findByUuid(refreshToken.userUuid); 56 | 57 | if (session != null && session.isActive() && user != null) { 58 | const newAccessToken = this.tokenProviderDomainService.createAccessToken( 59 | session.uuid, 60 | user.uuid, 61 | user.username, 62 | user.email, 63 | user.roles 64 | ); 65 | 66 | const newRefreshToken = this.tokenProviderDomainService.createRefreshToken( 67 | session.uuid, 68 | user.uuid, 69 | user.username, 70 | user.email, 71 | user.roles 72 | ); 73 | 74 | session.refreshTokenHash = await SessionRefreshTokenHash.createFromPlainRefreshToken(newRefreshToken.value); 75 | session.expiresAt = new SessionExpiresAt(refreshToken.expiresAt.value); 76 | session.userData = new SessionUserData( 77 | user.username.value, 78 | user.email.value, 79 | user.roles.map(role => role.value) 80 | ); 81 | 82 | this.sessionRepository.update(session); 83 | 84 | return ValidatedSessionResponse.createRefreshedSession(session, newAccessToken, newRefreshToken); 85 | } 86 | } 87 | 88 | throw new InvalidSessionException(); 89 | } 90 | } 91 | 92 | export { ValidateSessionUseCase }; 93 | -------------------------------------------------------------------------------- /src/application/sessions/validate/validated-session.response.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@domain/sessions/session'; 2 | import { AccessToken, RefreshToken } from '@domain/sessions/tokens'; 3 | import { Nullable } from '@domain/shared'; 4 | 5 | class ValidatedSessionResponse { 6 | public session: Nullable; 7 | 8 | public accessToken: Nullable; 9 | 10 | public refreshToken: Nullable; 11 | 12 | public wasRefreshed: boolean; 13 | 14 | constructor( 15 | session: Nullable, 16 | accessToken: Nullable, 17 | refreshToken: Nullable, 18 | wasRefreshed: boolean 19 | ) { 20 | this.session = session; 21 | this.accessToken = accessToken; 22 | this.refreshToken = refreshToken; 23 | this.wasRefreshed = wasRefreshed; 24 | } 25 | 26 | public static createValidatedSession( 27 | session: Session, 28 | accessToken: AccessToken, 29 | refreshToken: Nullable 30 | ): ValidatedSessionResponse { 31 | return new ValidatedSessionResponse(session, accessToken, refreshToken, false); 32 | } 33 | 34 | public static createRefreshedSession( 35 | session: Session, 36 | accessToken: AccessToken, 37 | refreshToken: RefreshToken 38 | ): ValidatedSessionResponse { 39 | return new ValidatedSessionResponse(session, accessToken, refreshToken, true); 40 | } 41 | } 42 | 43 | export { ValidatedSessionResponse }; 44 | -------------------------------------------------------------------------------- /src/application/shared/base-usecase.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'node:perf_hooks'; 2 | 3 | import { Logger } from '@domain/shared'; 4 | 5 | import { UseCaseRequest } from './usecase.request'; 6 | 7 | abstract class BaseUseCase { 8 | public async execute(request: IRequest): Promise { 9 | try { 10 | const startTime = performance.now(); 11 | 12 | request.validate(); 13 | 14 | const response = await this.performOperation(request); 15 | 16 | const endTime = performance.now(); 17 | 18 | const useCaseExecutionTime = endTime - startTime; 19 | 20 | Logger.info(`${this.constructor.name}.execute(${request}) took +${useCaseExecutionTime} ms to execute!`); 21 | 22 | return response; 23 | } catch (error) { 24 | Logger.error(`[@UseCase] ${this.constructor.name}.execute(${request}) threw the following error! --- ${error}`); 25 | throw error; 26 | } 27 | } 28 | 29 | protected abstract performOperation(request: IRequest): Promise; 30 | } 31 | 32 | export { BaseUseCase }; 33 | -------------------------------------------------------------------------------- /src/application/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-usecase'; 2 | export * from './usecase.decorator'; 3 | export * from './usecase.request'; 4 | -------------------------------------------------------------------------------- /src/application/shared/usecase.decorator.ts: -------------------------------------------------------------------------------- 1 | import { useDecorators } from '@tsed/core'; 2 | import { registerProvider } from '@tsed/di'; 3 | import * as emoji from 'node-emoji'; 4 | 5 | import { BaseUseCase } from '@application/shared/base-usecase'; 6 | import { Logger } from '@domain/shared'; 7 | 8 | const USE_CASES: BaseUseCase[] = []; 9 | 10 | type UseCaseOptions = { 11 | enabled?: boolean; 12 | type?: any; 13 | }; 14 | 15 | /* 16 | * The definition of this annotation should not depend on Ts.ED, 17 | * but the added difficulty of not depending on the framework at 18 | * this point does not outweigh the benefit. 19 | */ 20 | const UseCase = ({ enabled = true, type }: UseCaseOptions = {}): ClassDecorator => { 21 | const addUseCaseToRegistry = (target: any): void => { 22 | USE_CASES.push(target); 23 | }; 24 | 25 | const registerProviderDecorator = (target: any): void => { 26 | Logger.debug( 27 | `${emoji.get('zap')} [@UseCase] ${type?.name || target.name} points to ${target.name}. Status: ${ 28 | enabled ? 'REGISTERED' : 'NOT REGISTERED' 29 | }.` 30 | ); 31 | 32 | if (enabled) { 33 | registerProvider({ 34 | provide: type ?? target, 35 | useClass: target, 36 | type 37 | }); 38 | } 39 | }; 40 | 41 | return useDecorators(addUseCaseToRegistry, registerProviderDecorator); 42 | }; 43 | 44 | export { USE_CASES, UseCase }; 45 | -------------------------------------------------------------------------------- /src/application/shared/usecase.request.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from 'fast-equals'; 2 | 3 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 4 | import { InvalidParameterException } from '@domain/shared/exceptions'; 5 | 6 | abstract class UseCaseRequest { 7 | readonly triggeredBy: TriggeredBy; 8 | 9 | constructor(triggeredBy: TriggeredBy) { 10 | this.triggeredBy = triggeredBy; 11 | } 12 | 13 | public validate(): void { 14 | if (this.triggeredBy == null) { 15 | throw new InvalidParameterException('Triggered By must be provided'); 16 | } 17 | 18 | this.validatePayload(); 19 | } 20 | 21 | public equalsTo(other: UseCaseRequest): boolean { 22 | return deepEqual(this, other); 23 | } 24 | 25 | public toString(): string { 26 | return JSON.stringify(this); 27 | } 28 | 29 | protected abstract validatePayload(): void; 30 | } 31 | 32 | export { UseCaseRequest }; 33 | -------------------------------------------------------------------------------- /src/application/users/authentication/authenticate-user.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | class AuthenticateUserRequest extends UseCaseRequest { 6 | readonly username: string; 7 | 8 | readonly password: string; 9 | 10 | constructor(triggeredBy: TriggeredBy, username: string, password: string) { 11 | super(triggeredBy); 12 | this.username = username; 13 | this.password = password; 14 | } 15 | 16 | public static create(triggeredBy: TriggeredBy, username: string, password: string): AuthenticateUserRequest { 17 | return new AuthenticateUserRequest(triggeredBy, username, password); 18 | } 19 | 20 | protected validatePayload(): void { 21 | if (this.username == null) { 22 | throw new InvalidParameterException('Username must be provided'); 23 | } 24 | 25 | if (this.password == null) { 26 | throw new InvalidParameterException('Password must be provided'); 27 | } 28 | } 29 | } 30 | 31 | export { AuthenticateUserRequest }; 32 | -------------------------------------------------------------------------------- /src/application/users/authentication/authenticate-user.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { UserResponse } from '@application/users'; 3 | import { Nullable } from '@domain/shared'; 4 | import { User, UserRepository, UserUsername } from '@domain/users'; 5 | import { 6 | InvalidAuthenticationCredentialsException, 7 | InvalidAuthenticationUsernameException 8 | } from '@domain/users/authentication'; 9 | 10 | import { AuthenticateUserRequest } from './authenticate-user.request'; 11 | 12 | @UseCase() 13 | class AuthenticateUserUseCase extends BaseUseCase { 14 | private userRepository: UserRepository; 15 | 16 | constructor(userRepository: UserRepository) { 17 | super(); 18 | this.userRepository = userRepository; 19 | } 20 | 21 | public async performOperation({ username, password }: AuthenticateUserRequest): Promise { 22 | const user = await this.userRepository.findByUsername(new UserUsername(username)); 23 | 24 | this.ensureUserExists(user, username); 25 | 26 | await this.ensureCredentialsAreValid(user as User, password); 27 | 28 | return UserResponse.fromDomainModel(user as User); 29 | } 30 | 31 | private ensureUserExists(user: Nullable, username: string): void { 32 | if (!user) { 33 | throw new InvalidAuthenticationUsernameException(username); 34 | } 35 | } 36 | 37 | private async ensureCredentialsAreValid(user: User, password: string): Promise { 38 | if (!(await user?.passwordMatches(password))) { 39 | throw new InvalidAuthenticationCredentialsException(user.username.value); 40 | } 41 | } 42 | } 43 | 44 | export { AuthenticateUserUseCase }; 45 | -------------------------------------------------------------------------------- /src/application/users/authentication/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authenticate-user.request'; 2 | export * from './authenticate-user.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/users/find/find-user.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | class FindUserRequest extends UseCaseRequest { 6 | readonly uuid: string; 7 | 8 | constructor(triggeredBy: TriggeredBy, uuid: string) { 9 | super(triggeredBy); 10 | this.uuid = uuid; 11 | } 12 | 13 | public static create(triggeredBy: TriggeredBy, uuid: string): FindUserRequest { 14 | return new FindUserRequest(triggeredBy, uuid); 15 | } 16 | 17 | protected validatePayload(): void { 18 | if (this.uuid == null) { 19 | throw new InvalidParameterException('User UUID must be provided'); 20 | } 21 | } 22 | } 23 | 24 | export { FindUserRequest }; 25 | -------------------------------------------------------------------------------- /src/application/users/find/find-user.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { UserResponse } from '@application/users'; 3 | import { Nullable } from '@domain/shared'; 4 | import { User, UserNotExistsException, UserRepository, UserUuid } from '@domain/users'; 5 | 6 | import { FindUserRequest } from './find-user.request'; 7 | 8 | @UseCase() 9 | class FindUserUseCase extends BaseUseCase { 10 | private userRepository: UserRepository; 11 | 12 | constructor(userRepository: UserRepository) { 13 | super(); 14 | this.userRepository = userRepository; 15 | } 16 | 17 | public async performOperation({ uuid }: FindUserRequest): Promise { 18 | const user = await this.userRepository.findByUuid(new UserUuid(uuid)); 19 | 20 | this.ensureUserExists(user, uuid); 21 | 22 | return UserResponse.fromDomainModel(user as User); 23 | } 24 | 25 | private ensureUserExists(user: Nullable, uuid: string): void { 26 | if (!user) { 27 | throw new UserNotExistsException(uuid); 28 | } 29 | } 30 | } 31 | 32 | export { FindUserUseCase }; 33 | -------------------------------------------------------------------------------- /src/application/users/find/index.ts: -------------------------------------------------------------------------------- 1 | export * from './find-user.request'; 2 | export * from './find-user.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.response'; 2 | -------------------------------------------------------------------------------- /src/application/users/search-all/index.ts: -------------------------------------------------------------------------------- 1 | export * from './search-all-users.request'; 2 | export * from './search-all-users.usecase'; 3 | -------------------------------------------------------------------------------- /src/application/users/search-all/search-all-users.request.ts: -------------------------------------------------------------------------------- 1 | import { UseCaseRequest } from '@application/shared'; 2 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 3 | 4 | class SearchAllUsersRequest extends UseCaseRequest { 5 | public static create(triggeredBy: TriggeredBy): SearchAllUsersRequest { 6 | return new SearchAllUsersRequest(triggeredBy); 7 | } 8 | 9 | protected validatePayload(): void { 10 | // no validation needed 11 | } 12 | } 13 | 14 | export { SearchAllUsersRequest }; 15 | -------------------------------------------------------------------------------- /src/application/users/search-all/search-all-users.usecase.ts: -------------------------------------------------------------------------------- 1 | import { BaseUseCase, UseCase } from '@application/shared'; 2 | import { UserResponse } from '@application/users'; 3 | import { UserRepository } from '@domain/users'; 4 | 5 | import { SearchAllUsersRequest } from './search-all-users.request'; 6 | 7 | @UseCase() 8 | class SearchAllUsersUseCase extends BaseUseCase { 9 | private userRepository: UserRepository; 10 | 11 | constructor(userRepository: UserRepository) { 12 | super(); 13 | this.userRepository = userRepository; 14 | } 15 | 16 | public async performOperation(): Promise { 17 | const users = await this.userRepository.findAll(); 18 | return users.map(UserResponse.fromDomainModel); 19 | } 20 | } 21 | 22 | export { SearchAllUsersUseCase }; 23 | -------------------------------------------------------------------------------- /src/application/users/user.response.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@domain/users'; 2 | 3 | class UserResponse { 4 | readonly uuid: string; 5 | 6 | readonly gender: string; 7 | 8 | readonly firstName: string; 9 | 10 | readonly lastName: string; 11 | 12 | readonly birthDate: Date; 13 | 14 | readonly username: string; 15 | 16 | readonly email: string; 17 | 18 | readonly phoneNumber: string; 19 | 20 | readonly address: string; 21 | 22 | readonly profilePicUrl: string; 23 | 24 | readonly passwordHash: string; 25 | 26 | readonly roles: string[]; 27 | 28 | readonly verified: boolean; 29 | 30 | readonly enabled: boolean; 31 | 32 | constructor( 33 | uuid: string, 34 | gender: string, 35 | firstName: string, 36 | lastName: string, 37 | birthDate: Date, 38 | username: string, 39 | email: string, 40 | phoneNumber: string, 41 | address: string, 42 | profilePicUrl: string, 43 | passwordHash: string, 44 | roles: string[], 45 | verified: boolean, 46 | enabled: boolean 47 | ) { 48 | this.uuid = uuid; 49 | this.gender = gender; 50 | this.firstName = firstName; 51 | this.lastName = lastName; 52 | this.birthDate = birthDate; 53 | this.username = username; 54 | this.email = email; 55 | this.phoneNumber = phoneNumber; 56 | this.address = address; 57 | this.profilePicUrl = profilePicUrl; 58 | this.passwordHash = passwordHash; 59 | this.roles = roles; 60 | this.verified = verified; 61 | this.enabled = enabled; 62 | } 63 | 64 | public static fromDomainModel(user: User): UserResponse { 65 | return new UserResponse( 66 | user.uuid.value, 67 | user.gender.value, 68 | user.name.firstName, 69 | user.name.lastName, 70 | user.birthDate.value, 71 | user.username.value, 72 | user.email.value, 73 | user.phoneNumber.value, 74 | user.address.value, 75 | user.profilePicUrl.value, 76 | user.passwordHash.value, 77 | user.roles.map(role => role.value), 78 | user.verified, 79 | user.enabled 80 | ); 81 | } 82 | } 83 | 84 | export { UserResponse }; 85 | -------------------------------------------------------------------------------- /src/domain/health/health-status.ts: -------------------------------------------------------------------------------- 1 | import { DomainEntity } from '@domain/shared/entities'; 2 | 3 | class HealthStatus extends DomainEntity { 4 | readonly status: string; 5 | 6 | readonly message: string; 7 | 8 | readonly appVersion: string; 9 | 10 | constructor(status: string, message: string, appVersion: string) { 11 | super(); 12 | this.status = status; 13 | this.message = message; 14 | this.appVersion = appVersion; 15 | } 16 | 17 | public static create(status: string, message: string, appVersion: string): HealthStatus { 18 | return new HealthStatus(status, message, appVersion); 19 | } 20 | } 21 | 22 | export { HealthStatus }; 23 | -------------------------------------------------------------------------------- /src/domain/health/index.ts: -------------------------------------------------------------------------------- 1 | export * from './health-status'; 2 | -------------------------------------------------------------------------------- /src/domain/sessions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './invalid-session.exception'; 2 | export * from './session'; 3 | export * from './session.repository'; 4 | export * from './session-expires-at'; 5 | export * from './session-id'; 6 | export * from './session-refresh-token-hash'; 7 | export * from './session-revoked-at'; 8 | export * from './session-revoked-by'; 9 | export * from './session-revoked-reason'; 10 | export * from './session-user-data'; 11 | export * from './session-user-uuid'; 12 | export * from './session-uuid'; 13 | -------------------------------------------------------------------------------- /src/domain/sessions/invalid-session.exception.ts: -------------------------------------------------------------------------------- 1 | import { DomainException } from '@domain/shared/exceptions'; 2 | 3 | class InvalidSessionException extends DomainException { 4 | constructor() { 5 | super('invalid_session', `Invalid session`); 6 | } 7 | } 8 | 9 | export { InvalidSessionException }; 10 | -------------------------------------------------------------------------------- /src/domain/sessions/session-expires-at.ts: -------------------------------------------------------------------------------- 1 | import { DateValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionExpiresAt extends DateValueObject {} 4 | 5 | export { SessionExpiresAt }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-id.ts: -------------------------------------------------------------------------------- 1 | import { NumberValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionId extends NumberValueObject {} 4 | 5 | export { SessionId }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-refresh-token-hash.ts: -------------------------------------------------------------------------------- 1 | import { HasherDomainService } from '@domain/shared/services'; 2 | import { StringValueObject } from '@domain/shared/value-object'; 3 | 4 | class SessionRefreshTokenHash extends StringValueObject { 5 | public static async createFromPlainRefreshToken(refreshToken: string): Promise { 6 | const hashedRefreshToken = await HasherDomainService.hash(refreshToken); 7 | return new SessionRefreshTokenHash(hashedRefreshToken); 8 | } 9 | 10 | public async checkIfMatchesWithPlainRefreshToken(refreshToken: string): Promise { 11 | return HasherDomainService.compare(refreshToken, this.value); 12 | } 13 | } 14 | 15 | export { SessionRefreshTokenHash }; 16 | -------------------------------------------------------------------------------- /src/domain/sessions/session-revoked-at.ts: -------------------------------------------------------------------------------- 1 | import { DateValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionRevokedAt extends DateValueObject {} 4 | 5 | export { SessionRevokedAt }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-revoked-by.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionRevokedBy extends StringValueObject {} 4 | 5 | export { SessionRevokedBy }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-revoked-reason.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionRevocationReason extends StringValueObject {} 4 | 5 | export { SessionRevocationReason }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-user-data.ts: -------------------------------------------------------------------------------- 1 | import { CompositeValueObject } from '@domain/shared/value-object'; 2 | 3 | class SessionUserData extends CompositeValueObject { 4 | readonly username: string; 5 | 6 | readonly email: string; 7 | 8 | readonly roles: string[]; 9 | 10 | constructor(username: string, email: string, roles: string[]) { 11 | super(); 12 | this.username = username; 13 | this.email = email; 14 | this.roles = roles; 15 | } 16 | } 17 | 18 | export { SessionUserData }; 19 | -------------------------------------------------------------------------------- /src/domain/sessions/session-user-uuid.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@domain/shared/value-object'; 2 | 3 | class SessionUserUuid extends Uuid {} 4 | 5 | export { SessionUserUuid }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session-uuid.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@domain/shared/value-object'; 2 | 3 | class SessionUuid extends Uuid {} 4 | 5 | export { SessionUuid }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/session.repository.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | 3 | import { Session } from './session'; 4 | import { SessionUuid } from './session-uuid'; 5 | 6 | abstract class SessionRepository { 7 | public abstract findByUuid(uuid: SessionUuid): Promise>; 8 | 9 | public abstract create(refreshToken: Session): Promise; 10 | 11 | public abstract update(refreshToken: Session): Promise; 12 | 13 | public abstract delete(uuid: SessionUuid): Promise; 14 | } 15 | 16 | export { SessionRepository }; 17 | -------------------------------------------------------------------------------- /src/domain/sessions/session.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | import { Nullable } from '@domain/shared'; 4 | import { DomainEntity } from '@domain/shared/entities/domain-entity'; 5 | 6 | import { SessionExpiresAt } from './session-expires-at'; 7 | import { SessionId } from './session-id'; 8 | import { SessionRefreshTokenHash } from './session-refresh-token-hash'; 9 | import { SessionRevokedAt } from './session-revoked-at'; 10 | import { SessionRevokedBy } from './session-revoked-by'; 11 | import { SessionRevocationReason } from './session-revoked-reason'; 12 | import { SessionUserData } from './session-user-data'; 13 | import { SessionUserUuid } from './session-user-uuid'; 14 | import { SessionUuid } from './session-uuid'; 15 | 16 | interface SessionFlattened { 17 | id: Nullable; 18 | uuid: string; 19 | userUuid: string; 20 | username: string; 21 | email: string; 22 | roles: string[]; 23 | refreshTokenHash: string; 24 | expiresAt: Date; 25 | revokedAt: Nullable; 26 | revokedBy: Nullable; 27 | revocationReason: Nullable; 28 | } 29 | 30 | class Session extends DomainEntity { 31 | id: Nullable; 32 | 33 | uuid: SessionUuid; 34 | 35 | userUuid: SessionUserUuid; 36 | 37 | userData: SessionUserData; 38 | 39 | refreshTokenHash: SessionRefreshTokenHash; 40 | 41 | expiresAt: SessionExpiresAt; 42 | 43 | revokedAt: Nullable; 44 | 45 | revokedBy: Nullable; 46 | 47 | revocationReason: Nullable; 48 | 49 | constructor( 50 | id: Nullable, 51 | uuid: SessionUuid, 52 | userUuid: SessionUserUuid, 53 | userData: SessionUserData, 54 | refreshTokenHash: SessionRefreshTokenHash, 55 | expiresAt: SessionExpiresAt, 56 | revokedAt: Nullable, 57 | revokedBy: Nullable, 58 | revocationReason: Nullable 59 | ) { 60 | super(); 61 | this.id = id; 62 | this.uuid = uuid; 63 | this.userUuid = userUuid; 64 | this.userData = userData; 65 | this.refreshTokenHash = refreshTokenHash; 66 | this.expiresAt = expiresAt; 67 | this.revokedAt = revokedAt; 68 | this.revokedBy = revokedBy; 69 | this.revocationReason = revocationReason; 70 | } 71 | 72 | public static create( 73 | uuid: SessionUuid, 74 | userUuid: SessionUserUuid, 75 | refreshTokenHash: SessionRefreshTokenHash, 76 | userData: SessionUserData, 77 | expiresAt: SessionExpiresAt 78 | ): Session { 79 | return new Session( 80 | undefined, 81 | uuid, 82 | userUuid, 83 | userData, 84 | refreshTokenHash, 85 | expiresAt, 86 | undefined, 87 | undefined, 88 | undefined 89 | ); 90 | } 91 | 92 | public revoke(revocationAuthor: SessionRevokedBy, revocationReason: SessionRevocationReason): void { 93 | this.revokedAt = new SessionRevokedAt(DateTime.utc().toJSDate()); 94 | this.revokedBy = revocationAuthor; 95 | this.revocationReason = revocationReason; 96 | } 97 | 98 | public flat(): SessionFlattened { 99 | return { 100 | id: this.id?.value, 101 | uuid: this.uuid.value, 102 | userUuid: this.userUuid.value, 103 | username: this.userData.username, 104 | email: this.userData.email, 105 | roles: this.userData.roles, 106 | refreshTokenHash: this.refreshTokenHash.value, 107 | expiresAt: this.expiresAt.value, 108 | revokedAt: this.revokedAt?.value, 109 | revokedBy: this.revokedBy?.value, 110 | revocationReason: this.revocationReason?.value 111 | }; 112 | } 113 | 114 | public isActive(): boolean { 115 | const currentDate = DateTime.utc().toJSDate(); 116 | return this.expiresAt.value > currentDate && (this.revokedAt == null || this.revokedAt.value > currentDate); 117 | } 118 | 119 | public async refreshTokenMatches(plainRefreshToken: string): Promise { 120 | return this.refreshTokenHash.checkIfMatchesWithPlainRefreshToken(plainRefreshToken); 121 | } 122 | } 123 | 124 | export { Session, SessionFlattened }; 125 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/access-token.ts: -------------------------------------------------------------------------------- 1 | import { SessionUuid } from '@domain/sessions/session-uuid'; 2 | import { UserEmail, UserRole, UserUsername, UserUuid } from '@domain/users'; 3 | 4 | import { Token, TokenType } from './token'; 5 | import { TokenExpiresAt } from './token-expires-at'; 6 | 7 | class AccessToken extends Token { 8 | constructor( 9 | sessionUuid: SessionUuid, 10 | value: string, 11 | expiresAt: TokenExpiresAt, 12 | userUuid: UserUuid, 13 | username: UserUsername, 14 | email: UserEmail, 15 | roles: UserRole[] 16 | ) { 17 | super(TokenType.ACCESS_TOKEN, sessionUuid, value, expiresAt, userUuid, username, email, roles); 18 | } 19 | 20 | public static create( 21 | sessionUuid: SessionUuid, 22 | value: string, 23 | expiresAt: TokenExpiresAt, 24 | userUuid: UserUuid, 25 | username: UserUsername, 26 | email: UserEmail, 27 | roles: UserRole[] 28 | ): AccessToken { 29 | return new AccessToken(sessionUuid, value, expiresAt, userUuid, username, email, roles); 30 | } 31 | } 32 | 33 | export { AccessToken }; 34 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/index.ts: -------------------------------------------------------------------------------- 1 | export * from './access-token'; 2 | export * from './refresh-token'; 3 | export * from './token'; 4 | export * from './token-provider.domain-service'; 5 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/refresh-token.ts: -------------------------------------------------------------------------------- 1 | import { SessionUuid } from '@domain/sessions/session-uuid'; 2 | import { UserEmail, UserRole, UserUsername, UserUuid } from '@domain/users'; 3 | 4 | import { Token, TokenType } from './token'; 5 | import { TokenExpiresAt } from './token-expires-at'; 6 | 7 | class RefreshToken extends Token { 8 | constructor( 9 | sessionUuid: SessionUuid, 10 | value: string, 11 | expiresAt: TokenExpiresAt, 12 | userUuid: UserUuid, 13 | username: UserUsername, 14 | email: UserEmail, 15 | roles: UserRole[] 16 | ) { 17 | super(TokenType.REFRESH_TOKEN, sessionUuid, value, expiresAt, userUuid, username, email, roles); 18 | } 19 | 20 | public static create( 21 | sessionUuid: SessionUuid, 22 | value: string, 23 | expiresAt: TokenExpiresAt, 24 | userUuid: UserUuid, 25 | username: UserUsername, 26 | email: UserEmail, 27 | roles: UserRole[] 28 | ): RefreshToken { 29 | return new RefreshToken(sessionUuid, value, expiresAt, userUuid, username, email, roles); 30 | } 31 | } 32 | 33 | export { RefreshToken }; 34 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/token-expires-at.ts: -------------------------------------------------------------------------------- 1 | import { DateValueObject } from '@domain/shared/value-object'; 2 | 3 | class TokenExpiresAt extends DateValueObject {} 4 | 5 | export { TokenExpiresAt }; 6 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/token-provider.domain-service.ts: -------------------------------------------------------------------------------- 1 | import { SessionUuid } from '@domain/sessions/session-uuid'; 2 | import { Nullable } from '@domain/shared'; 3 | import { UserEmail, UserRole, UserUsername, UserUuid } from '@domain/users'; 4 | 5 | import { AccessToken } from './access-token'; 6 | import { RefreshToken } from './refresh-token'; 7 | 8 | abstract class TokenProviderDomainService { 9 | public abstract createAccessToken( 10 | sessionUuid: SessionUuid, 11 | userUuid: UserUuid, 12 | username: UserUsername, 13 | email: UserEmail, 14 | roles: UserRole[] 15 | ): AccessToken; 16 | 17 | public abstract createRefreshToken( 18 | sessionUuid: SessionUuid, 19 | userUuid: UserUuid, 20 | username: UserUsername, 21 | email: UserEmail, 22 | roles: UserRole[] 23 | ): RefreshToken; 24 | 25 | public abstract parseAccessToken(token: string): Nullable; 26 | 27 | public abstract parseRefreshToken(token: string): Nullable; 28 | } 29 | 30 | export { TokenProviderDomainService }; 31 | -------------------------------------------------------------------------------- /src/domain/sessions/tokens/token.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | import { SessionUuid } from '@domain/sessions/session-uuid'; 4 | import { UserEmail, UserRole, UserUsername, UserUuid } from '@domain/users'; 5 | 6 | import { TokenExpiresAt } from './token-expires-at'; 7 | 8 | enum TokenType { 9 | ACCESS_TOKEN = 'access_token', 10 | REFRESH_TOKEN = 'refresh_token' 11 | } 12 | 13 | interface TokenFlattened { 14 | type: TokenType; 15 | sessionUuid: string; 16 | value: string; 17 | expiresAt: Date; 18 | userUuid: string; 19 | username: string; 20 | email: string; 21 | roles: string[]; 22 | } 23 | 24 | abstract class Token { 25 | readonly type: TokenType; 26 | 27 | readonly sessionUuid: SessionUuid; 28 | 29 | readonly value: string; 30 | 31 | readonly expiresAt: TokenExpiresAt; 32 | 33 | readonly userUuid: UserUuid; 34 | 35 | readonly username: UserUsername; 36 | 37 | readonly email: UserEmail; 38 | 39 | readonly roles: UserRole[]; 40 | 41 | constructor( 42 | type: TokenType, 43 | sessionUuid: SessionUuid, 44 | value: string, 45 | expiresAt: TokenExpiresAt, 46 | userUuid: UserUuid, 47 | username: UserUsername, 48 | email: UserEmail, 49 | roles: UserRole[] 50 | ) { 51 | this.type = type; 52 | this.sessionUuid = sessionUuid; 53 | this.value = value; 54 | this.expiresAt = expiresAt; 55 | this.userUuid = userUuid; 56 | this.username = username; 57 | this.email = email; 58 | this.roles = roles; 59 | } 60 | 61 | public isExpired(): boolean { 62 | return this.expiresAt.value < DateTime.utc().toJSDate(); 63 | } 64 | 65 | public toString(): string { 66 | return JSON.stringify(this); 67 | } 68 | 69 | public flat(): TokenFlattened { 70 | return { 71 | type: this.type, 72 | sessionUuid: this.sessionUuid.value, 73 | value: this.value, 74 | expiresAt: this.expiresAt.value, 75 | userUuid: this.userUuid.value, 76 | username: this.username.value, 77 | email: this.email.value, 78 | roles: this.roles.map(role => role.value) 79 | }; 80 | } 81 | } 82 | 83 | export { Token, TokenFlattened, TokenType }; 84 | -------------------------------------------------------------------------------- /src/domain/shared/entities/domain-entity.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from 'fast-equals'; 2 | 3 | abstract class DomainEntity { 4 | public equalsTo(other: DomainEntity): boolean { 5 | return deepEqual(this, other); 6 | } 7 | 8 | public toString(): string { 9 | return JSON.stringify(this); 10 | } 11 | } 12 | 13 | export { DomainEntity }; 14 | -------------------------------------------------------------------------------- /src/domain/shared/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain-entity'; 2 | -------------------------------------------------------------------------------- /src/domain/shared/entities/triggered-by/index.ts: -------------------------------------------------------------------------------- 1 | export * from './triggered-by'; 2 | export * from './triggered-by-anonymous'; 3 | export * from './triggered-by-system'; 4 | export * from './triggered-by-user'; 5 | -------------------------------------------------------------------------------- /src/domain/shared/entities/triggered-by/triggered-by-anonymous.ts: -------------------------------------------------------------------------------- 1 | import { TriggeredBy } from './triggered-by'; 2 | 3 | class TriggeredByAnonymous extends TriggeredBy { 4 | public static IDENTIFIER = 'anonymous'; 5 | 6 | constructor() { 7 | super(TriggeredByAnonymous.IDENTIFIER); 8 | } 9 | 10 | public isByAnonymous(): boolean { 11 | return true; 12 | } 13 | 14 | public isBySystem(): boolean { 15 | return false; 16 | } 17 | 18 | public isByUser(): boolean { 19 | return false; 20 | } 21 | } 22 | 23 | export { TriggeredByAnonymous }; 24 | -------------------------------------------------------------------------------- /src/domain/shared/entities/triggered-by/triggered-by-system.ts: -------------------------------------------------------------------------------- 1 | import { TriggeredBy } from './triggered-by'; 2 | 3 | class TriggeredBySystem extends TriggeredBy { 4 | public static IDENTIFIER = 'system'; 5 | 6 | constructor() { 7 | super(TriggeredBySystem.IDENTIFIER); 8 | } 9 | 10 | public isByAnonymous(): boolean { 11 | return false; 12 | } 13 | 14 | public isBySystem(): boolean { 15 | return true; 16 | } 17 | 18 | public isByUser(): boolean { 19 | return false; 20 | } 21 | } 22 | 23 | export { TriggeredBySystem }; 24 | -------------------------------------------------------------------------------- /src/domain/shared/entities/triggered-by/triggered-by-user.ts: -------------------------------------------------------------------------------- 1 | import { TriggeredBy } from './triggered-by'; 2 | 3 | class TriggeredByUser extends TriggeredBy { 4 | readonly roles: string[]; 5 | 6 | constructor(user: string, roles: string[]) { 7 | super(user); 8 | this.roles = roles; 9 | } 10 | 11 | public isByAnonymous(): boolean { 12 | return false; 13 | } 14 | 15 | public isBySystem(): boolean { 16 | return false; 17 | } 18 | 19 | public isByUser(): boolean { 20 | return true; 21 | } 22 | } 23 | 24 | export { TriggeredByUser }; 25 | -------------------------------------------------------------------------------- /src/domain/shared/entities/triggered-by/triggered-by.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParameterException } from '@domain/shared/exceptions/invalid-parameter.exception'; 2 | 3 | abstract class TriggeredBy { 4 | who: string; 5 | 6 | protected constructor(who: string) { 7 | if (who == null) { 8 | throw new InvalidParameterException('Who identifier must be provided'); 9 | } 10 | 11 | this.who = who; 12 | } 13 | 14 | public abstract isByAnonymous(): boolean; 15 | 16 | public abstract isBySystem(): boolean; 17 | 18 | public abstract isByUser(): boolean; 19 | } 20 | 21 | export { TriggeredBy }; 22 | -------------------------------------------------------------------------------- /src/domain/shared/exceptions/domain.exception.ts: -------------------------------------------------------------------------------- 1 | abstract class DomainException extends Error { 2 | readonly code: string; 3 | 4 | readonly message: string; 5 | 6 | constructor(code: string, message: string) { 7 | super(message); 8 | this.name = new.target.name; 9 | this.code = code; 10 | this.message = message; 11 | } 12 | } 13 | 14 | export { DomainException }; 15 | -------------------------------------------------------------------------------- /src/domain/shared/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain.exception'; 2 | export * from './invalid-parameter.exception'; 3 | -------------------------------------------------------------------------------- /src/domain/shared/exceptions/invalid-parameter.exception.ts: -------------------------------------------------------------------------------- 1 | import { DomainException } from './domain.exception'; 2 | 3 | class InvalidParameterException extends DomainException { 4 | constructor(message: string) { 5 | super('invalid_parameter', message); 6 | } 7 | } 8 | 9 | export { InvalidParameterException }; 10 | -------------------------------------------------------------------------------- /src/domain/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /src/domain/shared/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable hexagonal-architecture/enforce */ 2 | import { Nullable } from '@domain/shared/types'; 3 | import { PINO_LOGGER } from '@infrastructure/shared/logger/pino-logger'; 4 | 5 | import { LoggerDomainService } from './services'; 6 | 7 | class Logger implements LoggerDomainService { 8 | private static loggerInstance: LoggerDomainService = PINO_LOGGER; 9 | 10 | private context: Nullable; 11 | 12 | constructor(); 13 | constructor(context: string); 14 | constructor(context?: string) { 15 | this.context = context || null; 16 | } 17 | 18 | public static debug(message: any, ...optionalParameters: any[]): void { 19 | Logger.loggerInstance.debug(message, ...optionalParameters); 20 | } 21 | 22 | public static info(message: any, ...optionalParameters: any[]): void { 23 | Logger.loggerInstance.info(message, ...optionalParameters); 24 | } 25 | 26 | public static warn(message: any, ...optionalParameters: any[]): void { 27 | Logger.loggerInstance.warn(message, ...optionalParameters); 28 | } 29 | 30 | public static error(message: any, ...optionalParameters: any[]): void { 31 | Logger.loggerInstance.error(message, ...optionalParameters); 32 | } 33 | 34 | public debug(message: any, ...optionalParameters: any[]): void { 35 | const optionalParametersWithContext = this.getMergedContextWithOptionalParameters(optionalParameters); 36 | Logger.loggerInstance.debug(message, ...optionalParametersWithContext); 37 | } 38 | 39 | public info(message: any, ...optionalParameters: any[]): void { 40 | const optionalParametersWithContext = this.getMergedContextWithOptionalParameters(optionalParameters); 41 | Logger.loggerInstance.info(message, ...optionalParametersWithContext); 42 | } 43 | 44 | public warn(message: any, ...optionalParameters: any[]): void { 45 | const optionalParametersWithContext = this.getMergedContextWithOptionalParameters(optionalParameters); 46 | Logger.loggerInstance.warn(message, ...optionalParametersWithContext); 47 | } 48 | 49 | public error(message: any, ...optionalParameters: any[]): void { 50 | const optionalParametersWithContext = this.getMergedContextWithOptionalParameters(optionalParameters); 51 | Logger.loggerInstance.error(message, ...optionalParametersWithContext); 52 | } 53 | 54 | private getMergedContextWithOptionalParameters(optionalParameters: any[]): any[] { 55 | return this.context ? optionalParameters.concat(this.context) : optionalParameters; 56 | } 57 | } 58 | 59 | export { Logger }; 60 | -------------------------------------------------------------------------------- /src/domain/shared/services/domain-service.decorator.ts: -------------------------------------------------------------------------------- 1 | import { useDecorators } from '@tsed/core'; 2 | import { registerProvider } from '@tsed/di'; 3 | import * as emoji from 'node-emoji'; 4 | 5 | import { Logger } from '@domain/shared'; 6 | 7 | const DOMAIN_SERVICES: any[] = []; 8 | 9 | type DomainServiceOptions = { 10 | enabled?: boolean; 11 | type?: any; 12 | }; 13 | 14 | /* 15 | * The definition of this annotation should not depend on Ts.ED, 16 | * but the added difficulty of not depending on the framework at 17 | * this point does not outweigh the benefit. 18 | */ 19 | const DomainService = ({ enabled = true, type }: DomainServiceOptions = {}): ClassDecorator => { 20 | const addDomainServiceToRegistry = (target: any): void => { 21 | DOMAIN_SERVICES.push(target); 22 | }; 23 | 24 | const registerProviderDecorator = (target: any): void => { 25 | Logger.debug( 26 | `${emoji.get('zap')} [@DomainService] ${type?.name || target.name} points to ${target.name}. Status: ${ 27 | enabled ? 'REGISTERED' : 'NOT REGISTERED' 28 | }.` 29 | ); 30 | 31 | if (enabled) { 32 | registerProvider({ 33 | provide: type ?? target, 34 | useClass: target, 35 | type 36 | }); 37 | } 38 | }; 39 | 40 | return useDecorators(addDomainServiceToRegistry, registerProviderDecorator); 41 | }; 42 | 43 | export { DOMAIN_SERVICES, DomainService }; 44 | -------------------------------------------------------------------------------- /src/domain/shared/services/hasher.domain-service.ts: -------------------------------------------------------------------------------- 1 | import * as argon2 from 'argon2'; 2 | 3 | import { DomainService } from './domain-service.decorator'; 4 | 5 | @DomainService() 6 | class HasherDomainService { 7 | public static async hash(plainText: string): Promise { 8 | return argon2.hash(plainText); 9 | } 10 | 11 | public static async compare(plainText: string, hash: string): Promise { 12 | return argon2.verify(hash, plainText); 13 | } 14 | } 15 | 16 | export { HasherDomainService }; 17 | -------------------------------------------------------------------------------- /src/domain/shared/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain-service.decorator'; 2 | export * from './hasher.domain-service'; 3 | export * from './logger.domain-service'; 4 | -------------------------------------------------------------------------------- /src/domain/shared/services/logger.domain-service.ts: -------------------------------------------------------------------------------- 1 | interface LoggerDomainService { 2 | debug(message: any, ...optionalParameters: any[]): void; 3 | 4 | info(message: any, ...optionalParameters: any[]): void; 5 | 6 | warn(message: any, ...optionalParameters: any[]): void; 7 | 8 | error(message: any, ...optionalParameters: any[]): void; 9 | } 10 | 11 | export { LoggerDomainService }; 12 | -------------------------------------------------------------------------------- /src/domain/shared/types.ts: -------------------------------------------------------------------------------- 1 | type Nullable = T | null | undefined; 2 | 3 | export { Nullable }; 4 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/composite-value-object.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from 'fast-equals'; 2 | 3 | abstract class CompositeValueObject { 4 | public equalsTo(other: CompositeValueObject): boolean { 5 | return other.constructor.name === this.constructor.name && deepEqual(this, other); 6 | } 7 | 8 | public toString(): string { 9 | return JSON.stringify(this); 10 | } 11 | } 12 | 13 | export { CompositeValueObject }; 14 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/date-value-object.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | import { ValueObject } from './value-object'; 4 | 5 | abstract class DateValueObject extends ValueObject { 6 | public static fromISOString(this: new (value: Date) => T, dateISOString: string): T { 7 | const dateObject = DateTime.fromISO(dateISOString).toJSDate(); 8 | return new this(dateObject); 9 | } 10 | 11 | public toString(): string { 12 | return this.value.toISOString(); 13 | } 14 | } 15 | 16 | export { DateValueObject }; 17 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/enum-value-object.ts: -------------------------------------------------------------------------------- 1 | abstract class EnumValueObject { 2 | readonly value: T; 3 | 4 | constructor( 5 | value: T, 6 | public readonly validValues: T[] 7 | ) { 8 | this.value = value; 9 | this.checkIfValueIsValid(value); 10 | } 11 | 12 | public checkIfValueIsValid(value: T): void { 13 | if (!this.validValues.includes(value)) { 14 | this.throwErrorForInvalidValue(value); 15 | } 16 | } 17 | 18 | protected abstract throwErrorForInvalidValue(value: T): void; 19 | } 20 | 21 | export { EnumValueObject }; 22 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/index.ts: -------------------------------------------------------------------------------- 1 | export * from './composite-value-object'; 2 | export * from './date-value-object'; 3 | export * from './enum-value-object'; 4 | export * from './number-value-object'; 5 | export * from './string-value-object'; 6 | export * from './uuid'; 7 | export * from './value-object'; 8 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/number-value-object.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from './value-object'; 2 | 3 | abstract class NumberValueObject extends ValueObject { 4 | public isBiggerThan(other: NumberValueObject): boolean { 5 | return this.value > other.value; 6 | } 7 | 8 | public toString(): string { 9 | return this.value.toString(); 10 | } 11 | } 12 | 13 | export { NumberValueObject }; 14 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/string-value-object.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from './value-object'; 2 | 3 | abstract class StringValueObject extends ValueObject { 4 | public toString(): string { 5 | return this.value; 6 | } 7 | } 8 | 9 | export { StringValueObject }; 10 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4, validate } from 'uuid'; 2 | 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | import { StringValueObject } from './string-value-object'; 6 | 7 | class Uuid extends StringValueObject { 8 | constructor(value: string) { 9 | super(value); 10 | this.ensureValueIsValid(value); 11 | } 12 | 13 | public static random(): Uuid { 14 | return new Uuid(v4()); 15 | } 16 | 17 | private ensureValueIsValid(value: string): void { 18 | if (!validate(value)) { 19 | throw new InvalidParameterException(`<${this.constructor.name}> does not allow the value <${value}>`); 20 | } 21 | } 22 | } 23 | 24 | export { Uuid }; 25 | -------------------------------------------------------------------------------- /src/domain/shared/value-object/value-object.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from 'fast-equals'; 2 | 3 | import { InvalidParameterException } from '@domain/shared/exceptions'; 4 | 5 | type Primitive = bigint | number | boolean | string | Date | symbol; 6 | 7 | abstract class ValueObject { 8 | readonly value: T; 9 | 10 | constructor(value: T) { 11 | this.value = value; 12 | this.ensureValueIsDefined(value); 13 | } 14 | 15 | public equalsTo(other: ValueObject): boolean { 16 | return other.constructor.name === this.constructor.name && other.value === this.value && deepEqual(this, other); 17 | } 18 | 19 | public toString(): string { 20 | return this.value.toString(); 21 | } 22 | 23 | private ensureValueIsDefined(value: T): void { 24 | if (value === null || value === undefined) { 25 | throw new InvalidParameterException('Value must be provided'); 26 | } 27 | } 28 | } 29 | 30 | export { ValueObject }; 31 | -------------------------------------------------------------------------------- /src/domain/users/authentication/index.ts: -------------------------------------------------------------------------------- 1 | export * from './invalid-authentication-credentials.exception'; 2 | export * from './invalid-authentication-username.exception'; 3 | -------------------------------------------------------------------------------- /src/domain/users/authentication/invalid-authentication-credentials.exception.ts: -------------------------------------------------------------------------------- 1 | import { DomainException } from '@domain/shared/exceptions'; 2 | 3 | class InvalidAuthenticationCredentialsException extends DomainException { 4 | constructor(username: string) { 5 | super('invalid_authentication_password', `The credentials for user <${username}> are invalid`); 6 | } 7 | } 8 | 9 | export { InvalidAuthenticationCredentialsException }; 10 | -------------------------------------------------------------------------------- /src/domain/users/authentication/invalid-authentication-username.exception.ts: -------------------------------------------------------------------------------- 1 | import { DomainException } from '@domain/shared/exceptions'; 2 | 3 | class InvalidAuthenticationUsernameException extends DomainException { 4 | constructor(username: string) { 5 | super('invalid_authentication_username', `The user with username <${username}> does not exist`); 6 | } 7 | } 8 | 9 | export { InvalidAuthenticationUsernameException }; 10 | -------------------------------------------------------------------------------- /src/domain/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user'; 2 | export * from './user.repository'; 3 | export * from './user-address'; 4 | export * from './user-birth-date'; 5 | export * from './user-email'; 6 | export * from './user-gender'; 7 | export * from './user-id'; 8 | export * from './user-name'; 9 | export * from './user-not-exists.exception'; 10 | export * from './user-password-hash'; 11 | export * from './user-phone-number'; 12 | export * from './user-profile-picture'; 13 | export * from './user-role'; 14 | export * from './user-username'; 15 | export * from './user-uuid'; 16 | -------------------------------------------------------------------------------- /src/domain/users/user-address.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserAddress extends StringValueObject {} 4 | 5 | export { UserAddress }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-birth-date.ts: -------------------------------------------------------------------------------- 1 | import { DateValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserBirthDate extends DateValueObject {} 4 | 5 | export { UserBirthDate }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-email.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserEmail extends StringValueObject {} 4 | 5 | export { UserEmail }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-gender.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParameterException } from '@domain/shared/exceptions'; 2 | import { EnumValueObject } from '@domain/shared/value-object/enum-value-object'; 3 | 4 | enum UserGenders { 5 | UNDEFINED = 'undefined', 6 | MALE = 'male', 7 | FEMALE = 'female' 8 | } 9 | 10 | class UserGender extends EnumValueObject { 11 | constructor(value: UserGenders) { 12 | super(value, Object.values(UserGenders)); 13 | } 14 | 15 | public static fromValue(value: string): UserGender { 16 | switch (value) { 17 | case UserGenders.UNDEFINED: { 18 | return new UserGender(UserGenders.UNDEFINED); 19 | } 20 | case UserGenders.MALE: { 21 | return new UserGender(UserGenders.MALE); 22 | } 23 | case UserGenders.FEMALE: { 24 | return new UserGender(UserGenders.FEMALE); 25 | } 26 | default: { 27 | throw new InvalidParameterException(`The gender ${value} is invalid`); 28 | } 29 | } 30 | } 31 | 32 | protected throwErrorForInvalidValue(value: UserGenders): void { 33 | throw new InvalidParameterException(`The gender ${value} is invalid`); 34 | } 35 | } 36 | 37 | export { UserGender, UserGenders }; 38 | -------------------------------------------------------------------------------- /src/domain/users/user-id.ts: -------------------------------------------------------------------------------- 1 | import { NumberValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserId extends NumberValueObject {} 4 | 5 | export { UserId }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-name.ts: -------------------------------------------------------------------------------- 1 | import { CompositeValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserName extends CompositeValueObject { 4 | readonly firstName: string; 5 | 6 | readonly lastName: string; 7 | 8 | constructor(firstName: string, lastName: string) { 9 | super(); 10 | this.firstName = firstName; 11 | this.lastName = lastName; 12 | } 13 | } 14 | 15 | export { UserName }; 16 | -------------------------------------------------------------------------------- /src/domain/users/user-not-exists.exception.ts: -------------------------------------------------------------------------------- 1 | import { DomainException } from '@domain/shared/exceptions'; 2 | 3 | class UserNotExistsException extends DomainException { 4 | constructor(uuid: string) { 5 | super('user_not_exists', `User with UUID <${uuid}> does not exists`); 6 | } 7 | } 8 | 9 | export { UserNotExistsException }; 10 | -------------------------------------------------------------------------------- /src/domain/users/user-password-hash.ts: -------------------------------------------------------------------------------- 1 | import { HasherDomainService } from '@domain/shared/services'; 2 | import { StringValueObject } from '@domain/shared/value-object'; 3 | 4 | class UserPasswordHash extends StringValueObject { 5 | public static async createFromPlainPassword(userPassword: string): Promise { 6 | const hashedUserPassword = await HasherDomainService.hash(userPassword); 7 | return new UserPasswordHash(hashedUserPassword); 8 | } 9 | 10 | public async checkIfMatchesWithPlainPassword(password: string): Promise { 11 | return HasherDomainService.compare(password, this.value); 12 | } 13 | } 14 | 15 | export { UserPasswordHash }; 16 | -------------------------------------------------------------------------------- /src/domain/users/user-phone-number.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserPhoneNumber extends StringValueObject {} 4 | 5 | export { UserPhoneNumber }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-profile-picture.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserProfilePicture extends StringValueObject {} 4 | 5 | export { UserProfilePicture }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-role.ts: -------------------------------------------------------------------------------- 1 | import { InvalidParameterException } from '@domain/shared/exceptions'; 2 | import { EnumValueObject } from '@domain/shared/value-object/enum-value-object'; 3 | 4 | enum UserRoles { 5 | ADMIN = 'admin', 6 | USER = 'user', 7 | AUDITOR = 'auditor' 8 | } 9 | 10 | class UserRole extends EnumValueObject { 11 | constructor(value: UserRoles) { 12 | super(value, Object.values(UserRoles)); 13 | } 14 | 15 | public static fromValue(value: string): UserRole { 16 | switch (value) { 17 | case UserRoles.ADMIN: { 18 | return new UserRole(UserRoles.ADMIN); 19 | } 20 | case UserRoles.USER: { 21 | return new UserRole(UserRoles.USER); 22 | } 23 | case UserRoles.AUDITOR: { 24 | return new UserRole(UserRoles.AUDITOR); 25 | } 26 | default: { 27 | throw new InvalidParameterException(`The role ${value} is invalid`); 28 | } 29 | } 30 | } 31 | 32 | protected throwErrorForInvalidValue(value: UserRoles): void { 33 | throw new InvalidParameterException(`The role ${value} is invalid`); 34 | } 35 | } 36 | 37 | export { UserRole, UserRoles }; 38 | -------------------------------------------------------------------------------- /src/domain/users/user-username.ts: -------------------------------------------------------------------------------- 1 | import { StringValueObject } from '@domain/shared/value-object'; 2 | 3 | class UserUsername extends StringValueObject {} 4 | 5 | export { UserUsername }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user-uuid.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@domain/shared/value-object'; 2 | 3 | class UserUuid extends Uuid {} 4 | 5 | export { UserUuid }; 6 | -------------------------------------------------------------------------------- /src/domain/users/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | import { UserUsername } from '@domain/users/user-username'; 3 | 4 | import { User } from './user'; 5 | import { UserEmail } from './user-email'; 6 | import { UserUuid } from './user-uuid'; 7 | 8 | abstract class UserRepository { 9 | public abstract findByUuid(uuid: UserUuid): Promise>; 10 | 11 | public abstract findByUsername(username: UserUsername): Promise>; 12 | 13 | public abstract findByEmail(email: UserEmail): Promise>; 14 | 15 | public abstract findAll(): Promise; 16 | 17 | public abstract create(user: User): Promise; 18 | 19 | public abstract update(user: User): Promise; 20 | 21 | public abstract delete(uuid: UserUuid): Promise; 22 | } 23 | 24 | export { UserRepository }; 25 | -------------------------------------------------------------------------------- /src/domain/users/user.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | import { DomainEntity } from '@domain/shared/entities/domain-entity'; 3 | 4 | import { UserAddress } from './user-address'; 5 | import { UserBirthDate } from './user-birth-date'; 6 | import { UserEmail } from './user-email'; 7 | import { UserGender } from './user-gender'; 8 | import { UserId } from './user-id'; 9 | import { UserName } from './user-name'; 10 | import { UserPasswordHash } from './user-password-hash'; 11 | import { UserPhoneNumber } from './user-phone-number'; 12 | import { UserProfilePicture } from './user-profile-picture'; 13 | import { UserRole } from './user-role'; 14 | import { UserUsername } from './user-username'; 15 | import { UserUuid } from './user-uuid'; 16 | 17 | interface UserFlattened { 18 | id: Nullable; 19 | uuid: string; 20 | gender: string; 21 | firstName: string; 22 | lastName: string; 23 | birthDate: Date; 24 | username: string; 25 | email: string; 26 | phoneNumber: string; 27 | address: string; 28 | profilePicUrl: string; 29 | passwordHash: string; 30 | roles: string[]; 31 | verified: boolean; 32 | enabled: boolean; 33 | } 34 | 35 | class User extends DomainEntity { 36 | id: Nullable; 37 | 38 | uuid: UserUuid; 39 | 40 | gender: UserGender; 41 | 42 | name: UserName; 43 | 44 | birthDate: UserBirthDate; 45 | 46 | username: UserUsername; 47 | 48 | email: UserEmail; 49 | 50 | phoneNumber: UserPhoneNumber; 51 | 52 | address: UserAddress; 53 | 54 | profilePicUrl: UserProfilePicture; 55 | 56 | passwordHash: UserPasswordHash; 57 | 58 | roles: UserRole[]; 59 | 60 | verified: boolean; 61 | 62 | enabled: boolean; 63 | 64 | constructor( 65 | id: Nullable, 66 | uuid: UserUuid, 67 | gender: UserGender, 68 | name: UserName, 69 | birthDate: UserBirthDate, 70 | username: UserUsername, 71 | email: UserEmail, 72 | phoneNumber: UserPhoneNumber, 73 | address: UserAddress, 74 | profilePicUrl: UserProfilePicture, 75 | passwordHash: UserPasswordHash, 76 | roles: UserRole[], 77 | verified: boolean, 78 | enabled: boolean 79 | ) { 80 | super(); 81 | this.id = id; 82 | this.uuid = uuid; 83 | this.gender = gender; 84 | this.name = name; 85 | this.birthDate = birthDate; 86 | this.username = username; 87 | this.email = email; 88 | this.phoneNumber = phoneNumber; 89 | this.address = address; 90 | this.profilePicUrl = profilePicUrl; 91 | this.passwordHash = passwordHash; 92 | this.roles = roles; 93 | this.verified = verified; 94 | this.enabled = enabled; 95 | } 96 | 97 | public static create( 98 | uuid: UserUuid, 99 | gender: UserGender, 100 | name: UserName, 101 | birthDate: UserBirthDate, 102 | username: UserUsername, 103 | email: UserEmail, 104 | phoneNumber: UserPhoneNumber, 105 | address: UserAddress, 106 | profilePicUrl: UserProfilePicture, 107 | passwordHash: UserPasswordHash, 108 | roles: UserRole[], 109 | verified: boolean, 110 | enabled: boolean 111 | ): User { 112 | return new User( 113 | undefined, 114 | uuid, 115 | gender, 116 | name, 117 | birthDate, 118 | username, 119 | email, 120 | phoneNumber, 121 | address, 122 | profilePicUrl, 123 | passwordHash, 124 | roles, 125 | verified, 126 | enabled 127 | ); 128 | } 129 | 130 | public flat(): UserFlattened { 131 | return { 132 | id: this.id?.value, 133 | uuid: this.uuid.value, 134 | gender: this.gender.value, 135 | firstName: this.name.firstName, 136 | lastName: this.name.lastName, 137 | birthDate: this.birthDate.value, 138 | username: this.username.value, 139 | email: this.email.value, 140 | phoneNumber: this.phoneNumber.value, 141 | address: this.address.value, 142 | profilePicUrl: this.profilePicUrl.value, 143 | passwordHash: this.passwordHash.value, 144 | roles: this.roles.map(role => role.value), 145 | verified: this.verified, 146 | enabled: this.enabled 147 | }; 148 | } 149 | 150 | public async passwordMatches(plainUserPassword: string): Promise { 151 | return this.passwordHash.checkIfMatchesWithPlainPassword(plainUserPassword); 152 | } 153 | } 154 | 155 | export { User, UserFlattened }; 156 | -------------------------------------------------------------------------------- /src/healthcheck.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Health check to verify if the service is alive. 3 | */ 4 | 5 | import * as http from 'node:http'; 6 | 7 | import { AppConfig } from '@presentation/rest/config'; 8 | 9 | const options = { 10 | host: 'localhost', 11 | port: AppConfig.PORT, 12 | timeout: 2000, 13 | path: `${AppConfig.BASE_PATH}/healthz` 14 | }; 15 | 16 | const request = http.request(options, (response: http.IncomingMessage) => { 17 | process.exitCode = response.statusCode === 200 ? 0 : 1; 18 | process.exit(); 19 | }); 20 | 21 | request.on('error', () => { 22 | process.exit(1); 23 | }); 24 | 25 | request.end(); 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'source-map-support/register'; 3 | 4 | import { PlatformExpress } from '@tsed/platform-express'; 5 | import * as emoji from 'node-emoji'; 6 | 7 | import { Logger } from '@domain/shared'; 8 | import { bootstrap } from '@infrastructure/shared'; 9 | import { Server } from '@presentation/rest/server'; 10 | 11 | const start = async (): Promise => { 12 | await bootstrap(); 13 | 14 | const platform = await PlatformExpress.bootstrap(Server, { ...(await Server.getConfiguration()) }); 15 | await platform.listen(); 16 | 17 | process 18 | .on('SIGINT', () => { 19 | platform.stop(); 20 | Logger.info(`${emoji.get('zap')} Server gracefully shut down!`); 21 | }) 22 | .on('unhandledRejection', error => { 23 | Logger.error(`${emoji.get('skull')} uncaughtException captured: ${error}`); 24 | }) 25 | .on('uncaughtException', error => { 26 | Logger.error(`${emoji.get('skull')} uncaughtException captured: ${error}`); 27 | }); 28 | }; 29 | start(); 30 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './prisma-session.mapper'; 2 | export * from './prisma-session.repository'; 3 | export * from './redis-session'; 4 | export * from './redis-session.mapper'; 5 | export * from './redis-session.repository'; 6 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/prisma-session.mapper.ts: -------------------------------------------------------------------------------- 1 | import { SessionModel } from '@tsed/prisma'; 2 | 3 | import { 4 | SessionRefreshTokenHash, 5 | SessionRevocationReason, 6 | SessionRevokedAt, 7 | SessionRevokedBy 8 | } from '@domain/sessions/'; 9 | import { Session } from '@domain/sessions/session'; 10 | import { SessionExpiresAt } from '@domain/sessions/session-expires-at'; 11 | import { SessionId } from '@domain/sessions/session-id'; 12 | import { SessionUserData } from '@domain/sessions/session-user-data'; 13 | import { SessionUuid } from '@domain/sessions/session-uuid'; 14 | 15 | class PrismaSessionMapper { 16 | public static toDomainModel(sessionPersistenceModel: SessionModel): Session { 17 | const { username, email, roles } = JSON.parse(sessionPersistenceModel.userData); 18 | return new Session( 19 | new SessionId(sessionPersistenceModel.id), 20 | new SessionUuid(sessionPersistenceModel.uuid), 21 | new SessionUuid(sessionPersistenceModel.userUuid), 22 | new SessionUserData(username, email, roles), 23 | new SessionRefreshTokenHash(sessionPersistenceModel.refreshTokenHash), 24 | new SessionExpiresAt(sessionPersistenceModel.expiresAt), 25 | sessionPersistenceModel.revokedAt ? new SessionRevokedAt(sessionPersistenceModel.revokedAt) : null, 26 | sessionPersistenceModel.revokedBy ? new SessionRevokedBy(sessionPersistenceModel.revokedBy) : null, 27 | sessionPersistenceModel.revocationReason 28 | ? new SessionRevocationReason(sessionPersistenceModel.revocationReason) 29 | : null 30 | ); 31 | } 32 | 33 | public static toPersistenceModel(session: Session): SessionModel { 34 | const sessionPersistenceModel = new SessionModel(); 35 | if (session.id != null) { 36 | sessionPersistenceModel.id = session.id.value; 37 | } 38 | sessionPersistenceModel.uuid = session.uuid.value; 39 | sessionPersistenceModel.userUuid = session.userUuid.value; 40 | sessionPersistenceModel.userData = session.userData.toString(); 41 | sessionPersistenceModel.refreshTokenHash = session.refreshTokenHash.value; 42 | sessionPersistenceModel.expiresAt = session.expiresAt.value; 43 | sessionPersistenceModel.revokedAt = session.revokedAt?.value || null; 44 | sessionPersistenceModel.revokedBy = session.revokedBy?.value || null; 45 | sessionPersistenceModel.revocationReason = session.revocationReason?.value || null; 46 | return sessionPersistenceModel; 47 | } 48 | } 49 | 50 | export { PrismaSessionMapper }; 51 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/prisma-session.repository.ts: -------------------------------------------------------------------------------- 1 | import { SessionModel, SessionsRepository } from '@tsed/prisma'; 2 | 3 | import { Session } from '@domain/sessions/session'; 4 | import { SessionRepository } from '@domain/sessions/session.repository'; 5 | import { SessionUuid } from '@domain/sessions/session-uuid'; 6 | import { Nullable } from '@domain/shared'; 7 | import { GlobalConfig } from '@infrastructure/shared/config'; 8 | import { Repository } from '@infrastructure/shared/persistence'; 9 | import { RepositoryAction } from '@infrastructure/shared/persistence/base-repository'; 10 | import { PrismaBaseRepository } from '@infrastructure/shared/persistence/prisma/prisma-base-repository'; 11 | 12 | import { PrismaSessionMapper } from './prisma-session.mapper'; 13 | 14 | @Repository({ enabled: GlobalConfig.STORE_SESSIONS_IN_DB, type: SessionRepository }) 15 | class PrismaSessionRepository extends PrismaBaseRepository implements SessionRepository { 16 | private sessionRepository: SessionsRepository; 17 | 18 | constructor(sessionRepository: SessionsRepository) { 19 | super(); 20 | this.sessionRepository = sessionRepository; 21 | } 22 | 23 | public async findByUuid(uuid: SessionUuid): Promise> { 24 | const session = await this.sessionRepository.findFirst({ 25 | where: { uuid: uuid.value, deletedAt: null, revokedAt: null } 26 | }); 27 | 28 | return session ? PrismaSessionMapper.toDomainModel(session) : null; 29 | } 30 | 31 | public async create(session: Session): Promise { 32 | const createdSession = await this.sessionRepository.create({ 33 | data: this.getAuditablePersitenceModel(RepositoryAction.CREATE, PrismaSessionMapper.toPersistenceModel(session)) 34 | }); 35 | return PrismaSessionMapper.toDomainModel(createdSession); 36 | } 37 | 38 | public async update(session: Session): Promise { 39 | const updatedSession = await this.sessionRepository.update({ 40 | where: { uuid: session.uuid.value }, 41 | data: this.getAuditablePersitenceModel(RepositoryAction.UPDATE, PrismaSessionMapper.toPersistenceModel(session)) 42 | }); 43 | return PrismaSessionMapper.toDomainModel(updatedSession); 44 | } 45 | 46 | public async delete(uuid: SessionUuid): Promise { 47 | await this.sessionRepository.update({ 48 | where: { uuid: uuid.value }, 49 | data: this.getAuditablePersitenceModel(RepositoryAction.DELETE) 50 | }); 51 | } 52 | } 53 | 54 | export { PrismaSessionRepository }; 55 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/redis-session.mapper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SessionRefreshTokenHash, 3 | SessionRevocationReason, 4 | SessionRevokedAt, 5 | SessionRevokedBy 6 | } from '@domain/sessions/'; 7 | import { Session } from '@domain/sessions/session'; 8 | import { SessionExpiresAt } from '@domain/sessions/session-expires-at'; 9 | import { SessionUserData } from '@domain/sessions/session-user-data'; 10 | import { SessionUuid } from '@domain/sessions/session-uuid'; 11 | 12 | import { RedisSession } from './redis-session'; 13 | 14 | class RedisSessionMapper { 15 | public static toDomainModel(sessionPersistenceModel: RedisSession | Record): Session { 16 | const { username, email, roles } = JSON.parse(sessionPersistenceModel.userData); 17 | return new Session( 18 | null, 19 | new SessionUuid(sessionPersistenceModel.uuid), 20 | new SessionUuid(sessionPersistenceModel.userUuid), 21 | new SessionUserData(username, email, roles), 22 | new SessionRefreshTokenHash(sessionPersistenceModel.refreshTokenHash), 23 | SessionExpiresAt.fromISOString(sessionPersistenceModel.expiresAt), 24 | sessionPersistenceModel.revokedAt ? SessionRevokedAt.fromISOString(sessionPersistenceModel.revokedAt) : null, 25 | sessionPersistenceModel.revokedBy ? new SessionRevokedBy(sessionPersistenceModel.revokedBy) : null, 26 | sessionPersistenceModel.revocationReason 27 | ? new SessionRevocationReason(sessionPersistenceModel.revocationReason) 28 | : null 29 | ); 30 | } 31 | 32 | public static toPersistenceModel(session: Session): RedisSession { 33 | return { 34 | uuid: session.uuid.value, 35 | userUuid: session.userUuid.value, 36 | userData: session.userData.toString(), 37 | refreshTokenHash: session.refreshTokenHash.value, 38 | expiresAt: session.expiresAt.toString(), 39 | revokedAt: session.revokedAt?.toString() || null, 40 | revokedBy: session.revokedBy?.value || null, 41 | revocationReason: session.revocationReason?.value || null 42 | }; 43 | } 44 | } 45 | 46 | export { RedisSessionMapper }; 47 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/redis-session.repository.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@tsed/di'; 2 | import isEmpty from 'just-is-empty'; 3 | 4 | import { Session } from '@domain/sessions/session'; 5 | import { SessionRepository } from '@domain/sessions/session.repository'; 6 | import { SessionUuid } from '@domain/sessions/session-uuid'; 7 | import { Nullable } from '@domain/shared'; 8 | import { REDIS_CONNECTION, RedisConnection } from '@infrastructure/shared/cache/cache'; 9 | import { GlobalConfig } from '@infrastructure/shared/config'; 10 | import { Repository } from '@infrastructure/shared/persistence'; 11 | import { RepositoryAction } from '@infrastructure/shared/persistence/base-repository'; 12 | import { RedisBaseRepository } from '@infrastructure/shared/persistence/redis/redis-base-repository'; 13 | 14 | import { RedisSession } from './redis-session'; 15 | import { RedisSessionMapper } from './redis-session.mapper'; 16 | 17 | @Repository({ enabled: GlobalConfig.STORE_SESSIONS_IN_CACHE, type: SessionRepository }) 18 | class RedisSessionRepository extends RedisBaseRepository implements SessionRepository { 19 | protected readonly repositoryKey = 'sessions'; 20 | 21 | private connection: RedisConnection; 22 | 23 | constructor(@Inject(REDIS_CONNECTION) connection: RedisConnection) { 24 | super(); 25 | this.connection = connection; 26 | } 27 | 28 | public async findByUuid(uuid: SessionUuid): Promise> { 29 | const cachedSession = await this.connection.hgetall(this.getKeyPrefix(uuid.value)); 30 | 31 | return isEmpty(cachedSession) || cachedSession.deletedAt ? null : RedisSessionMapper.toDomainModel(cachedSession); 32 | } 33 | 34 | public async create(session: Session): Promise { 35 | await this.connection.hset( 36 | this.getKeyPrefix(session.uuid.value), 37 | this.getAuditablePersitenceModel(RepositoryAction.CREATE, RedisSessionMapper.toPersistenceModel(session)) 38 | ); 39 | return session; 40 | } 41 | 42 | public async update(session: Session): Promise { 43 | await this.connection.hset( 44 | this.getKeyPrefix(session.uuid.value), 45 | this.getAuditablePersitenceModel(RepositoryAction.UPDATE, RedisSessionMapper.toPersistenceModel(session)) 46 | ); 47 | return session; 48 | } 49 | 50 | public async delete(uuid: SessionUuid): Promise { 51 | await this.connection.hset( 52 | this.getKeyPrefix(uuid.value), 53 | this.getAuditablePersitenceModel(RepositoryAction.DELETE) 54 | ); 55 | } 56 | } 57 | 58 | export { RedisSessionRepository }; 59 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/redis-session.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | 3 | interface RedisSession { 4 | uuid: string; 5 | userUuid: string; 6 | userData: string; 7 | refreshTokenHash: string; 8 | expiresAt: string; 9 | revokedAt: Nullable; 10 | revokedBy: Nullable; 11 | revocationReason: Nullable; 12 | } 13 | 14 | export { RedisSession }; 15 | -------------------------------------------------------------------------------- /src/infrastructure/sessions/tokens/jwt-token-provider.domain-service.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { DateTime } from 'luxon'; 3 | 4 | import { SessionUuid } from '@domain/sessions/session-uuid'; 5 | import { AccessToken, RefreshToken, TokenProviderDomainService } from '@domain/sessions/tokens'; 6 | import { TokenType } from '@domain/sessions/tokens/token'; 7 | import { TokenExpiresAt } from '@domain/sessions/tokens/token-expires-at'; 8 | import { Nullable } from '@domain/shared'; 9 | import { DomainService } from '@domain/shared/services'; 10 | import { UserEmail, UserRole, UserUsername, UserUuid } from '@domain/users'; 11 | import { GlobalConfig } from '@infrastructure/shared/config'; 12 | 13 | @DomainService({ type: TokenProviderDomainService }) 14 | class JwtTokenProvider extends TokenProviderDomainService { 15 | private readonly jwtAlgorithm: any = 'HS512'; 16 | 17 | private readonly jwtSecret: string = GlobalConfig.JWT_SECRET; 18 | 19 | private readonly jwtExpiration: number = GlobalConfig.JWT_EXPIRATION; 20 | 21 | private readonly jwtRefreshExpiration: number = GlobalConfig.JWT_REFRESH_EXPIRATION; 22 | 23 | public createAccessToken( 24 | sessionUuid: SessionUuid, 25 | userUuid: UserUuid, 26 | username: UserUsername, 27 | email: UserEmail, 28 | roles: UserRole[] 29 | ): AccessToken { 30 | const userRoles = roles.map(role => role.value); 31 | const expiresAt = this.getAccessTokenExpiration(); 32 | const jwtToken = jwt.sign( 33 | { 34 | type: TokenType.ACCESS_TOKEN, 35 | sessionUuid: sessionUuid.value, 36 | userUuid: userUuid.value, 37 | username: username.value, 38 | email: email.value, 39 | roles: userRoles, 40 | exp: Math.floor(DateTime.fromJSDate(expiresAt.value).toSeconds()) 41 | }, 42 | this.jwtSecret, 43 | { 44 | algorithm: this.jwtAlgorithm 45 | } 46 | ); 47 | return AccessToken.create(sessionUuid, jwtToken, expiresAt, userUuid, username, email, roles); 48 | } 49 | 50 | public createRefreshToken( 51 | sessionUuid: SessionUuid, 52 | userUuid: UserUuid, 53 | username: UserUsername, 54 | email: UserEmail, 55 | roles: UserRole[] 56 | ): RefreshToken { 57 | const userRoles = roles.map(role => role.value); 58 | const expiresAt = this.getRefreshTokenExpiration(); 59 | const jwtToken = jwt.sign( 60 | { 61 | type: TokenType.REFRESH_TOKEN, 62 | sessionUuid: sessionUuid.value, 63 | userUuid: userUuid.value, 64 | username: username.value, 65 | email: email.value, 66 | roles: userRoles, 67 | exp: Math.floor(DateTime.fromJSDate(expiresAt.value).toSeconds()) 68 | }, 69 | this.jwtSecret, 70 | { 71 | algorithm: this.jwtAlgorithm 72 | } 73 | ); 74 | 75 | return RefreshToken.create(sessionUuid, jwtToken, expiresAt, userUuid, username, email, roles); 76 | } 77 | 78 | public parseAccessToken(token: string): Nullable { 79 | try { 80 | const { type, sessionUuid, userUuid, username, email, roles, exp } = jwt.verify(token, this.jwtSecret, { 81 | algorithms: [this.jwtAlgorithm] 82 | }); 83 | return type === TokenType.ACCESS_TOKEN 84 | ? AccessToken.create( 85 | new SessionUuid(sessionUuid), 86 | token, 87 | new TokenExpiresAt(DateTime.fromSeconds(exp).toJSDate()), 88 | new UserUuid(userUuid), 89 | new UserUsername(username), 90 | new UserEmail(email), 91 | roles.map(UserRole.fromValue) 92 | ) 93 | : null; 94 | } catch { 95 | return null; 96 | } 97 | } 98 | 99 | public parseRefreshToken(token: string): Nullable { 100 | try { 101 | const { type, sessionUuid, userUuid, username, email, roles, exp } = jwt.verify(token, this.jwtSecret, { 102 | algorithms: [this.jwtAlgorithm] 103 | }); 104 | return type === TokenType.REFRESH_TOKEN 105 | ? RefreshToken.create( 106 | new SessionUuid(sessionUuid), 107 | token, 108 | new TokenExpiresAt(DateTime.fromSeconds(exp).toJSDate()), 109 | new UserUuid(userUuid), 110 | new UserUsername(username), 111 | new UserEmail(email), 112 | roles.map(UserRole.fromValue) 113 | ) 114 | : null; 115 | } catch { 116 | return null; 117 | } 118 | } 119 | 120 | private getAccessTokenExpiration(): TokenExpiresAt { 121 | return new TokenExpiresAt( 122 | DateTime.utc() 123 | .plus({ millisecond: this.jwtExpiration * 3_600_000 }) 124 | .toJSDate() 125 | ); 126 | } 127 | 128 | private getRefreshTokenExpiration(): TokenExpiresAt { 129 | return new TokenExpiresAt( 130 | DateTime.utc() 131 | .plus({ millisecond: this.jwtRefreshExpiration * 3_600_000 }) 132 | .toJSDate() 133 | ); 134 | } 135 | } 136 | 137 | export { JwtTokenProvider }; 138 | -------------------------------------------------------------------------------- /src/infrastructure/shared/authentication/authentication-utils.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | 3 | import { Authentication } from './authentication'; 4 | 5 | class AuthenticationUtils { 6 | private static authentication: Nullable; 7 | 8 | public static getAuthentication(): Nullable { 9 | return this.authentication; 10 | } 11 | 12 | public static setAuthentication(authentication: Authentication): void { 13 | this.authentication = authentication; 14 | } 15 | 16 | public static clearAuthentication(): void { 17 | this.authentication = null; 18 | } 19 | } 20 | 21 | export { AuthenticationUtils }; 22 | -------------------------------------------------------------------------------- /src/infrastructure/shared/authentication/authentication.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '@domain/shared'; 2 | 3 | class Authentication { 4 | readonly uuid: Nullable; 5 | 6 | readonly username: Nullable; 7 | 8 | readonly email: Nullable; 9 | 10 | readonly roles: string[]; 11 | 12 | constructor(uuid: Nullable, username: Nullable, email: Nullable, roles: string[]) { 13 | this.uuid = uuid; 14 | this.username = username; 15 | this.email = email; 16 | this.roles = roles; 17 | } 18 | 19 | public static create( 20 | uuid: Nullable, 21 | username: Nullable, 22 | email: Nullable, 23 | roles: string[] 24 | ): Authentication { 25 | return new Authentication(uuid, username, email, roles); 26 | } 27 | 28 | public static createEmpty(): Authentication { 29 | return new Authentication(null, null, null, []); 30 | } 31 | } 32 | 33 | export { Authentication }; 34 | -------------------------------------------------------------------------------- /src/infrastructure/shared/authentication/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authentication'; 2 | -------------------------------------------------------------------------------- /src/infrastructure/shared/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'node:perf_hooks'; 2 | 3 | import * as emoji from 'node-emoji'; 4 | 5 | import { Logger } from '@domain/shared'; 6 | 7 | import { Cache } from './cache/cache'; 8 | import { DependencyInjection } from './di/dependency-injection'; 9 | 10 | interface BootstrapResult { 11 | bootstrapDuration: number; 12 | } 13 | 14 | const bootstrap = async (): Promise => { 15 | const decorateLoggerMessage = (message: string): string => { 16 | return `${emoji.get('zap')} ${message}`; 17 | }; 18 | 19 | const bootstrapStartTime = performance.now(); 20 | 21 | Logger.info(decorateLoggerMessage('Bootstrapping infrastructure...')); 22 | 23 | Logger.info(decorateLoggerMessage('Initializing DI container...')); 24 | 25 | await DependencyInjection.initialize(); 26 | 27 | Logger.info(decorateLoggerMessage('DI container initialized!')); 28 | 29 | Logger.info(decorateLoggerMessage('Initializing cache...')); 30 | 31 | await Cache.initialize(); 32 | 33 | Logger.info(decorateLoggerMessage('Cache initialized!')); 34 | 35 | const bootstrapEndTime = performance.now(); 36 | 37 | const bootstrapDuration = bootstrapEndTime - bootstrapStartTime; 38 | 39 | Logger.info(decorateLoggerMessage(`Infrastructure bootstrap took +${bootstrapDuration} ms to execute!`)); 40 | 41 | return { bootstrapDuration }; 42 | }; 43 | 44 | export { bootstrap, BootstrapResult }; 45 | -------------------------------------------------------------------------------- /src/infrastructure/shared/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import { registerConnectionProvider } from '@tsed/ioredis'; 2 | import Redis from 'ioredis'; 3 | 4 | import { Logger } from '@domain/shared'; 5 | 6 | const REDIS_CONNECTION = Symbol.for('REDIS_CONNECTION'); 7 | type RedisConnection = Redis; 8 | 9 | class Cache { 10 | public static initialize = async (): Promise => { 11 | try { 12 | registerConnectionProvider({ 13 | provide: REDIS_CONNECTION, 14 | name: 'default' 15 | }); 16 | } catch (error) { 17 | Logger.error(`[@Bootstrap] ${this.constructor.name}.initialize() threw the following error! --- ${error}`); 18 | process.exit(1); 19 | } 20 | }; 21 | } 22 | 23 | export { Cache, REDIS_CONNECTION, RedisConnection }; 24 | -------------------------------------------------------------------------------- /src/infrastructure/shared/config/environment.ts: -------------------------------------------------------------------------------- 1 | import * as dotEnvConfig from 'dotenv-defaults/config'; 2 | import * as dotenvExpand from 'dotenv-expand'; 3 | 4 | dotenvExpand.expand(dotEnvConfig); 5 | 6 | const getEnvironmentString = (key: string, defaultValue: string): string => { 7 | return process.env[String(key)] || defaultValue; 8 | }; 9 | 10 | const getEnvironmentNumber = (key: string, defaultValue: number): number => { 11 | return Number(process.env[String(key)] || defaultValue); 12 | }; 13 | 14 | export { getEnvironmentNumber, getEnvironmentString }; 15 | -------------------------------------------------------------------------------- /src/infrastructure/shared/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environment'; 2 | export * from './infrastructure.config'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/shared/config/infrastructure.config.ts: -------------------------------------------------------------------------------- 1 | import { getEnvironmentNumber, getEnvironmentString } from './environment'; 2 | 3 | const GlobalConfig = Object.freeze({ 4 | ENVIRONMENT: getEnvironmentString('NODE_ENV', 'development'), 5 | IS_TEST: getEnvironmentString('NODE_ENV', 'development') === 'test', 6 | IS_DEVELOPMENT: getEnvironmentString('NODE_ENV', 'development') === 'development', 7 | IS_PRODUCTION: getEnvironmentString('NODE_ENV', 'development') === 'production', 8 | LOGS_ENABLED: getEnvironmentString('LOGS_ENABLED', 'true') === 'true', 9 | LOGS_FOLDER: getEnvironmentString('LOGS_FOLDER', 'logs'), 10 | JWT_SECRET: getEnvironmentString('JWT_SECRET', 'jwtSecretPassphrase'), 11 | JWT_EXPIRATION: getEnvironmentNumber('JWT_EXPIRATION', 1), 12 | JWT_REFRESH_EXPIRATION: getEnvironmentNumber('JWT_REFRESH_EXPIRATION', 6), 13 | STORE_SESSIONS_IN_CACHE: getEnvironmentString('SESSIONS_STORAGE', 'cache') !== 'db', 14 | STORE_SESSIONS_IN_DB: getEnvironmentString('SESSIONS_STORAGE', 'cache') === 'db', 15 | PINO_LOGGER_KEY: 'pino-logger' 16 | }); 17 | 18 | const DatabaseConfig = Object.freeze({ 19 | DB_TYPE: getEnvironmentString('DB_TYPE', 'postgresql') as any, 20 | DB_HOST: getEnvironmentString('DB_HOST', 'localhost'), 21 | DB_PORT: getEnvironmentNumber('DB_PORT', 5432), 22 | DB_USER: getEnvironmentString('DB_USER', 'mars-user'), 23 | DB_PASSWORD: getEnvironmentString('DB_PASSWORD', 'mars-password'), 24 | DB_NAME: getEnvironmentString('DB_NAME', 'express-typescript-skeleton-postgres') 25 | }); 26 | 27 | const CacheConfig = Object.freeze({ 28 | CACHE_HOST: getEnvironmentString('CACHE_HOST', 'localhost'), 29 | CACHE_PORT: getEnvironmentNumber('CACHE_PORT', 6379), 30 | CACHE_PASSWORD: getEnvironmentString('CACHE_PASSWORD', 'mars-password'), 31 | CACHE_DB: getEnvironmentNumber('CACHE_DB', 0) 32 | }); 33 | 34 | export { CacheConfig, DatabaseConfig, GlobalConfig }; 35 | -------------------------------------------------------------------------------- /src/infrastructure/shared/di/dependency-injection.ts: -------------------------------------------------------------------------------- 1 | import { registerProvider } from '@tsed/di'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { Logger } from '@domain/shared'; 5 | 6 | class DependencyInjection { 7 | public static initialize = async (): Promise => { 8 | try { 9 | // This is just an example of how to register a provider manually 10 | this.initializeProviders([ 11 | { 12 | type: DependencyInjection, 13 | targetClass: DependencyInjection 14 | } 15 | ]); 16 | } catch (error) { 17 | Logger.error(`[@Bootstrap] ${this.constructor.name}.initialize() threw the following error! --- ${error}`); 18 | process.exit(1); 19 | } 20 | }; 21 | 22 | private static initializeProvider = (providerConfiguration: { type: any; targetClass: any }): void => { 23 | registerProvider({ 24 | provide: providerConfiguration.type, 25 | useClass: providerConfiguration.targetClass, 26 | type: providerConfiguration.type 27 | }); 28 | Logger.debug( 29 | `${emoji.get('zap')} ${providerConfiguration.type?.name || providerConfiguration.targetClass.name} points to ${ 30 | providerConfiguration.targetClass.name 31 | }. Status: REGISTERED.` 32 | ); 33 | }; 34 | 35 | private static initializeProviders = ( 36 | providersConfiguration: { 37 | type: any; 38 | targetClass: any; 39 | }[] 40 | ): void => { 41 | providersConfiguration.forEach(providerConfiguration => 42 | this.initializeProvider({ 43 | type: providerConfiguration.type, 44 | targetClass: providerConfiguration.targetClass 45 | }) 46 | ); 47 | }; 48 | } 49 | 50 | export { DependencyInjection }; 51 | -------------------------------------------------------------------------------- /src/infrastructure/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bootstrap'; 2 | export * from './infrastructure-service.decorator'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/shared/infrastructure-service.decorator.ts: -------------------------------------------------------------------------------- 1 | import { useDecorators } from '@tsed/core'; 2 | import { registerProvider } from '@tsed/di'; 3 | import * as emoji from 'node-emoji'; 4 | 5 | import { Logger } from '@domain/shared'; 6 | 7 | type InfrastructureServiceOptions = { 8 | enabled?: boolean; 9 | type?: any; 10 | }; 11 | 12 | const InfrastructureService = ({ enabled = true, type }: InfrastructureServiceOptions = {}): ClassDecorator => { 13 | const registerProviderDecorator = (target: any): void => { 14 | Logger.debug( 15 | `${emoji.get('zap')} [@InfrastructureService] ${type?.name || target.name} points to ${target.name}. Status: ${ 16 | enabled ? 'REGISTERED' : 'NOT REGISTERED' 17 | }.` 18 | ); 19 | 20 | if (enabled) { 21 | registerProvider({ 22 | provide: type ?? target, 23 | useClass: target, 24 | type 25 | }); 26 | } 27 | }; 28 | 29 | return useDecorators(registerProviderDecorator); 30 | }; 31 | 32 | export { InfrastructureService }; 33 | -------------------------------------------------------------------------------- /src/infrastructure/shared/logger/pino-rotate-file.transport.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter, { once } from 'node:events'; 2 | 3 | import * as FileStreamRotator from 'file-stream-rotator'; 4 | 5 | interface PinoRotateFileOptions { 6 | folder: string; 7 | filename: string; 8 | extension: string; 9 | } 10 | 11 | export default async (options: PinoRotateFileOptions): Promise => { 12 | const stream = FileStreamRotator.getStream({ 13 | filename: `${options.folder}/${options.filename}.%DATE%`, 14 | frequency: 'date', 15 | extension: `.${options.extension}`, 16 | utc: true, 17 | verbose: false, 18 | date_format: 'YYYYMM', 19 | audit_file: `${options.folder}/log-audit.json` 20 | }); 21 | await once(stream, 'open'); 22 | return stream; 23 | }; 24 | 25 | export { PinoRotateFileOptions }; 26 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/base-repository.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | import { AuthenticationUtils } from '@infrastructure/shared/authentication/authentication-utils'; 4 | 5 | enum RepositoryAction { 6 | CREATE = 'create', 7 | UPDATE = 'update', 8 | DELETE = 'delete' 9 | } 10 | 11 | abstract class BaseRepository { 12 | protected getAuditablePersitenceModel(auditAction: RepositoryAction, persistenceModel?: T): T { 13 | const username = AuthenticationUtils.getAuthentication()?.username; 14 | const actionDate = DateTime.utc().toISO(); 15 | 16 | return { 17 | ...((persistenceModel || {}) as T), 18 | ...(Array.of(RepositoryAction.CREATE).includes(auditAction) && { createdBy: username }), 19 | ...(Array.of(RepositoryAction.CREATE).includes(auditAction) && { createdAt: actionDate }), 20 | ...(Array.of(RepositoryAction.CREATE, RepositoryAction.UPDATE).includes(auditAction) && { updatedBy: username }), 21 | ...(Array.of(RepositoryAction.CREATE, RepositoryAction.UPDATE).includes(auditAction) && { 22 | updatedAt: actionDate 23 | }), 24 | ...(Array.of(RepositoryAction.DELETE).includes(auditAction) && { deletedBy: username }), 25 | ...(Array.of(RepositoryAction.DELETE).includes(auditAction) && { deletedAt: actionDate }) 26 | }; 27 | } 28 | } 29 | 30 | export { BaseRepository, RepositoryAction }; 31 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base-repository'; 2 | export * from './repository.decorator'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/prisma/prisma-base-repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from '@infrastructure/shared/persistence'; 2 | 3 | abstract class PrismaBaseRepository extends BaseRepository {} 4 | 5 | export { PrismaBaseRepository }; 6 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DB_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | binaryTargets = ["native"] 9 | } 10 | 11 | generator tsed { 12 | provider = "tsed-prisma" 13 | binaryTargets = ["native"] 14 | //output = "./generated/tsed" 15 | //emitTranspiledCode = false 16 | } 17 | 18 | enum Gender { 19 | UNDEFINED 20 | MALE 21 | FEMALE 22 | } 23 | 24 | enum Role { 25 | ADMIN 26 | USER 27 | AUDITOR 28 | } 29 | 30 | model User { 31 | id Int @id @default(autoincrement()) 32 | uuid String @unique @db.VarChar(1000) 33 | gender Gender @default(UNDEFINED) 34 | firstName String @db.VarChar(255) 35 | lastName String @db.VarChar(500) 36 | birthDate DateTime @db.Date 37 | username String @unique @db.VarChar(100) 38 | email String @unique @db.VarChar(255) 39 | phoneNumber String @db.VarChar(100) 40 | address String @db.VarChar(1500) 41 | profilePicUrl String @db.VarChar(2500) 42 | passwordHash String @db.VarChar(100) 43 | verified Boolean @default(false) 44 | enabled Boolean @default(true) 45 | roles Role[] @default([USER]) 46 | createdAt DateTime? @default(now()) @db.Timestamptz(3) 47 | createdBy String? @db.VarChar(255) 48 | updatedAt DateTime? @default(now()) @updatedAt @db.Timestamptz(3) 49 | updatedBy String? @db.VarChar(255) 50 | deletedAt DateTime? @db.Timestamptz(3) 51 | deletedBy String? @db.VarChar(255) 52 | } 53 | 54 | model Session { 55 | id Int @id @default(autoincrement()) 56 | uuid String @unique @db.VarChar(1000) 57 | userUuid String @db.VarChar(1000) 58 | userData Json @db.JsonB 59 | refreshTokenHash String @db.VarChar(5000) 60 | revokedAt DateTime? @db.Timestamptz(3) 61 | revokedBy String? @db.VarChar(255) 62 | revocationReason String? @db.VarChar(1000) 63 | expiresAt DateTime @db.Timestamptz(3) 64 | createdAt DateTime? @default(now()) @db.Timestamptz(3) 65 | createdBy String? @db.VarChar(255) 66 | updatedAt DateTime? @default(now()) @updatedAt @db.Timestamptz(3) 67 | updatedBy String? @db.VarChar(255) 68 | deletedAt DateTime? @db.Timestamptz(3) 69 | deletedBy String? @db.VarChar(255) 70 | } 71 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | /* spell-checker: disable */ 2 | import { randomUUID } from 'node:crypto'; 3 | 4 | import { randAvatar, randFullAddress, randPastDate, randPhoneNumber } from '@ngneat/falso'; 5 | import { Gender, Prisma, PrismaClient, Role } from '@prisma/client'; 6 | import { DateTime } from 'luxon'; 7 | 8 | const prisma = new PrismaClient(); 9 | 10 | const CURRENT_DATE = DateTime.utc().toJSDate(); 11 | const SEED_USER = 'seed'; 12 | 13 | const USERS: Prisma.UserCreateInput[] = [ 14 | { 15 | uuid: randomUUID(), 16 | gender: Gender.UNDEFINED, 17 | firstName: 'Jane', 18 | lastName: 'Doe', 19 | birthDate: randPastDate(), 20 | username: 'janedoe', 21 | email: 'hello@janedoe.com', 22 | phoneNumber: randPhoneNumber(), 23 | address: randFullAddress(), 24 | profilePicUrl: randAvatar(), 25 | passwordHash: '$argon2id$v=19$m=4096,t=3,p=1$SnlvMThQRzN5cWhoWnkySQ$YOsVi7+r5v8ngtUmfBNCJpv3Nx/Om6s2nvfEOgSqgKs', 26 | verified: true, 27 | enabled: true, 28 | roles: Array.of(Role.ADMIN), 29 | createdAt: CURRENT_DATE, 30 | createdBy: SEED_USER, 31 | updatedAt: CURRENT_DATE, 32 | updatedBy: SEED_USER 33 | } 34 | ]; 35 | 36 | const main = async (): Promise => { 37 | try { 38 | USERS.forEach(async user => { 39 | await prisma.user.create({ 40 | data: user 41 | }); 42 | }); 43 | } catch { 44 | process.exit(1); 45 | } finally { 46 | await prisma.$disconnect(); 47 | } 48 | }; 49 | 50 | main(); 51 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/redis/redis-base-repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from '@infrastructure/shared/persistence'; 2 | 3 | abstract class RedisBaseRepository extends BaseRepository { 4 | protected abstract readonly repositoryKey: string; 5 | 6 | protected getKeyPrefix(value: string): string { 7 | return `${this.repositoryKey}:${value}`; 8 | } 9 | } 10 | 11 | export { RedisBaseRepository }; 12 | -------------------------------------------------------------------------------- /src/infrastructure/shared/persistence/repository.decorator.ts: -------------------------------------------------------------------------------- 1 | import { useDecorators } from '@tsed/core'; 2 | import { registerProvider } from '@tsed/di'; 3 | import * as emoji from 'node-emoji'; 4 | 5 | import { Logger } from '@domain/shared'; 6 | 7 | type RepositoryOptions = { 8 | enabled?: boolean; 9 | type?: any; 10 | }; 11 | 12 | const Repository = ({ enabled = true, type }: RepositoryOptions = {}): ClassDecorator => { 13 | const registerProviderDecorator = (target: any): void => { 14 | Logger.debug( 15 | `${emoji.get('zap')} [@Repository] ${type?.name || target.name} points to ${target.name}. Status: ${ 16 | enabled ? 'REGISTERED' : 'NOT REGISTERED' 17 | }.` 18 | ); 19 | 20 | if (enabled) { 21 | registerProvider({ 22 | provide: type ?? target, 23 | useClass: target, 24 | type 25 | }); 26 | } 27 | }; 28 | 29 | return useDecorators(registerProviderDecorator); 30 | }; 31 | 32 | export { Repository }; 33 | -------------------------------------------------------------------------------- /src/infrastructure/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './prisma-user.mapper'; 2 | export * from './prisma-user.repository'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/users/prisma-user.mapper.ts: -------------------------------------------------------------------------------- 1 | import { Gender, Role, UserModel } from '@tsed/prisma'; 2 | 3 | import { 4 | User, 5 | UserAddress, 6 | UserBirthDate, 7 | UserEmail, 8 | UserGender, 9 | UserId, 10 | UserName, 11 | UserPasswordHash, 12 | UserPhoneNumber, 13 | UserProfilePicture, 14 | UserRole, 15 | UserUuid 16 | } from '@domain/users'; 17 | import { UserUsername } from '@domain/users/user-username'; 18 | 19 | class PrismaUserMapper { 20 | public static toDomainModel(userPersistenceModel: UserModel): User { 21 | return new User( 22 | new UserId(userPersistenceModel.id), 23 | new UserUuid(userPersistenceModel.uuid), 24 | UserGender.fromValue(userPersistenceModel.gender.toLowerCase()), 25 | new UserName(userPersistenceModel.firstName, userPersistenceModel.lastName), 26 | new UserBirthDate(userPersistenceModel.birthDate), 27 | new UserUsername(userPersistenceModel.username), 28 | new UserEmail(userPersistenceModel.email), 29 | new UserPhoneNumber(userPersistenceModel.phoneNumber), 30 | new UserAddress(userPersistenceModel.address), 31 | new UserProfilePicture(userPersistenceModel.profilePicUrl), 32 | new UserPasswordHash(userPersistenceModel.passwordHash), 33 | userPersistenceModel.roles.map(role => UserRole.fromValue(role.toLowerCase())), 34 | userPersistenceModel.verified, 35 | userPersistenceModel.enabled 36 | ); 37 | } 38 | 39 | public static toPersistenceModel(user: User): UserModel { 40 | const userPersistenceModel = new UserModel(); 41 | if (user.id != null) { 42 | userPersistenceModel.id = user.id.value; 43 | } 44 | userPersistenceModel.uuid = user.uuid.value; 45 | userPersistenceModel.gender = user.gender.value.toUpperCase(); 46 | userPersistenceModel.firstName = user.name.firstName; 47 | userPersistenceModel.lastName = user.name.lastName; 48 | userPersistenceModel.birthDate = user.birthDate.value; 49 | userPersistenceModel.username = user.username.value; 50 | userPersistenceModel.email = user.email.value; 51 | userPersistenceModel.phoneNumber = user.phoneNumber.value; 52 | userPersistenceModel.address = user.address.value; 53 | userPersistenceModel.profilePicUrl = user.profilePicUrl.value; 54 | userPersistenceModel.passwordHash = user.passwordHash.value; 55 | userPersistenceModel.roles = user.roles.map(role => role.value.toUpperCase()); 56 | userPersistenceModel.verified = user.verified; 57 | userPersistenceModel.enabled = user.enabled; 58 | return userPersistenceModel; 59 | } 60 | } 61 | 62 | export { PrismaUserMapper }; 63 | -------------------------------------------------------------------------------- /src/infrastructure/users/prisma-user.repository.ts: -------------------------------------------------------------------------------- 1 | import { UserModel, UsersRepository } from '@tsed/prisma'; 2 | 3 | import { Nullable } from '@domain/shared'; 4 | import { UserEmail, UserRepository, UserUuid } from '@domain/users'; 5 | import { User } from '@domain/users/user'; 6 | import { UserUsername } from '@domain/users/user-username'; 7 | import { BaseRepository, RepositoryAction } from '@infrastructure/shared/persistence/base-repository'; 8 | import { Repository } from '@infrastructure/shared/persistence/repository.decorator'; 9 | 10 | import { PrismaUserMapper } from './prisma-user.mapper'; 11 | 12 | @Repository({ enabled: true, type: UserRepository }) 13 | class PrismaUserRepository extends BaseRepository implements UserRepository { 14 | private usersRepository: UsersRepository; 15 | 16 | constructor(usersRepository: UsersRepository) { 17 | super(); 18 | this.usersRepository = usersRepository; 19 | } 20 | 21 | public async findByUuid(uuid: UserUuid): Promise> { 22 | const user = await this.usersRepository.findFirst({ 23 | where: { uuid: uuid.value, deletedAt: null } 24 | }); 25 | 26 | return user ? PrismaUserMapper.toDomainModel(user) : null; 27 | } 28 | 29 | public async findByUsername(username: UserUsername): Promise> { 30 | const user = await this.usersRepository.findFirst({ 31 | where: { username: username.value, deletedAt: null } 32 | }); 33 | 34 | return user ? PrismaUserMapper.toDomainModel(user) : null; 35 | } 36 | 37 | public async findByEmail(email: UserEmail): Promise> { 38 | const user = await this.usersRepository.findFirst({ 39 | where: { email: email.value, deletedAt: null } 40 | }); 41 | 42 | return user ? PrismaUserMapper.toDomainModel(user) : null; 43 | } 44 | 45 | public async findAll(): Promise { 46 | const users = await this.usersRepository.findMany({ 47 | where: { deletedAt: null } 48 | }); 49 | 50 | return users.map(PrismaUserMapper.toDomainModel); 51 | } 52 | 53 | public async create(user: User): Promise { 54 | const createdUser = await this.usersRepository.create({ 55 | data: this.getAuditablePersitenceModel(RepositoryAction.CREATE, PrismaUserMapper.toPersistenceModel(user)) 56 | }); 57 | return PrismaUserMapper.toDomainModel(createdUser); 58 | } 59 | 60 | public async update(user: User): Promise { 61 | const updatedUser = await this.usersRepository.update({ 62 | where: { uuid: user.uuid.value }, 63 | data: this.getAuditablePersitenceModel(RepositoryAction.UPDATE, PrismaUserMapper.toPersistenceModel(user)) 64 | }); 65 | return PrismaUserMapper.toDomainModel(updatedUser); 66 | } 67 | 68 | public async delete(uuid: UserUuid): Promise { 69 | await this.usersRepository.update({ 70 | where: { uuid: uuid.value }, 71 | data: this.getAuditablePersitenceModel(RepositoryAction.DELETE) 72 | }); 73 | } 74 | } 75 | 76 | export { PrismaUserRepository }; 77 | -------------------------------------------------------------------------------- /src/presentation/rest/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { sync as readPackageJsonSync } from 'read-pkg'; 2 | 3 | import { getEnvironmentNumber, getEnvironmentString } from '@infrastructure/shared/config/environment'; 4 | 5 | const AppInfo = Object.freeze({ 6 | APP_VERSION: getEnvironmentString('APP_VERSION', readPackageJsonSync().version), 7 | APP_NAME: getEnvironmentString('APP_NAME', readPackageJsonSync().name), 8 | APP_DESCRIPTION: getEnvironmentString('APP_DESCRIPTION', readPackageJsonSync().description || 'N/A'), 9 | AUTHOR_NAME: getEnvironmentString('AUTHOR_NAME', readPackageJsonSync().author?.name || 'N/A'), 10 | AUTHOR_EMAIL: getEnvironmentString('AUTHOR_EMAIL', readPackageJsonSync().author?.email || 'N/A'), 11 | AUTHOR_WEBSITE: getEnvironmentString('AUTHOR_WEBSITE', readPackageJsonSync().author?.url || 'N/A') 12 | }); 13 | 14 | const AppConfig = Object.freeze({ 15 | PORT: getEnvironmentNumber('PORT', 5000), 16 | BASE_PATH: getEnvironmentString('BASE_PATH', '/api'), 17 | AUTHORIZATION_ACCESS_TOKEN_HEADER_NAME: 'authorization', 18 | ACCESS_TOKEN_HEADER_NAME: 'access-token', 19 | ACCESS_TOKEN_COOKIE_NAME: 'access-token', 20 | REFRESH_TOKEN_COOKIE_NAME: 'refresh-token', 21 | REFRESH_TOKEN_HEADER_NAME: 'refresh-token', 22 | TRIGGERED_BY_CONTEXT_KEY: 'triggeredBy', 23 | AUTHENTICATION_CONTEXT_KEY: 'authentication' 24 | }); 25 | 26 | export { AppConfig, AppInfo }; 27 | -------------------------------------------------------------------------------- /src/presentation/rest/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app.config'; 2 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/authentication/authenticated-user.api-response.ts: -------------------------------------------------------------------------------- 1 | import { Default, Email, Enum, Format, Property } from '@tsed/schema'; 2 | 3 | import { UserResponse } from '@application/users'; 4 | import { UserGenders, UserRoles } from '@domain/users'; 5 | 6 | class AuthenticatedUserApiResponse { 7 | @Property() 8 | readonly uuid: string; 9 | 10 | @Enum(UserGenders) 11 | @Default(UserGenders.UNDEFINED) 12 | readonly gender: string; 13 | 14 | @Property() 15 | readonly firstName: string; 16 | 17 | @Property() 18 | readonly lastName: string; 19 | 20 | @Format('date') 21 | readonly birthDate: Date; 22 | 23 | @Property() 24 | readonly username: string; 25 | 26 | @Email() 27 | readonly email: string; 28 | 29 | @Property() 30 | readonly phoneNumber: string; 31 | 32 | @Property() 33 | readonly address: string; 34 | 35 | @Property() 36 | readonly profilePicUrl: string; 37 | 38 | @Enum(UserRoles) 39 | @Default(UserRoles.USER) 40 | readonly roles: string[]; 41 | 42 | constructor( 43 | uuid: string, 44 | gender: string, 45 | firstName: string, 46 | lastName: string, 47 | birthDate: Date, 48 | username: string, 49 | email: string, 50 | phoneNumber: string, 51 | address: string, 52 | profilePicUrl: string, 53 | roles: string[] 54 | ) { 55 | this.uuid = uuid; 56 | this.gender = gender; 57 | this.firstName = firstName; 58 | this.lastName = lastName; 59 | this.birthDate = birthDate; 60 | this.username = username; 61 | this.email = email; 62 | this.phoneNumber = phoneNumber; 63 | this.address = address; 64 | this.profilePicUrl = profilePicUrl; 65 | this.roles = roles; 66 | } 67 | 68 | public static fromUserResponse(user: UserResponse): AuthenticatedUserApiResponse { 69 | return new AuthenticatedUserApiResponse( 70 | user.uuid, 71 | user.gender, 72 | user.firstName, 73 | user.lastName, 74 | user.birthDate, 75 | user.username, 76 | user.email, 77 | user.phoneNumber, 78 | user.address, 79 | user.profilePicUrl, 80 | user.roles 81 | ); 82 | } 83 | } 84 | 85 | export { AuthenticatedUserApiResponse }; 86 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/authentication/user-successfully-authenticated.api-response.ts: -------------------------------------------------------------------------------- 1 | import { CollectionOf, Email, Property } from '@tsed/schema'; 2 | 3 | import { SessionResponse } from '@application/sessions'; 4 | 5 | class UserSuccessfullyAuthenticatedApiResponse { 6 | @Property() 7 | readonly uuid: string; 8 | 9 | @Property() 10 | readonly username: string; 11 | 12 | @Email() 13 | readonly email: string; 14 | 15 | @CollectionOf(String) 16 | readonly roles: string[]; 17 | 18 | @Property() 19 | readonly accessToken: string; 20 | 21 | @Property() 22 | readonly refreshToken: string; 23 | 24 | constructor( 25 | uuid: string, 26 | username: string, 27 | email: string, 28 | roles: string[], 29 | accessToken: string, 30 | refreshToken: string 31 | ) { 32 | this.uuid = uuid; 33 | this.username = username; 34 | this.email = email; 35 | this.roles = roles; 36 | this.accessToken = accessToken; 37 | this.refreshToken = refreshToken; 38 | } 39 | 40 | public static create( 41 | uuid: string, 42 | username: string, 43 | email: string, 44 | roles: string[], 45 | accessToken: string, 46 | refreshToken: string 47 | ): UserSuccessfullyAuthenticatedApiResponse { 48 | return new UserSuccessfullyAuthenticatedApiResponse(uuid, username, email, roles, accessToken, refreshToken); 49 | } 50 | 51 | public static fromSessionResponse( 52 | sessionInformationHolder: SessionResponse 53 | ): UserSuccessfullyAuthenticatedApiResponse { 54 | return new UserSuccessfullyAuthenticatedApiResponse( 55 | sessionInformationHolder.session.userUuid.value, 56 | sessionInformationHolder.session.userData.username, 57 | sessionInformationHolder.session.userData.email, 58 | sessionInformationHolder.session.userData.roles, 59 | sessionInformationHolder.accessToken.value, 60 | sessionInformationHolder.refreshToken.value 61 | ); 62 | } 63 | } 64 | 65 | export { UserSuccessfullyAuthenticatedApiResponse }; 66 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/health/health-status.api-response.ts: -------------------------------------------------------------------------------- 1 | import { Default, Property } from '@tsed/schema'; 2 | 3 | import { HealthStatusResponse } from '@application/health'; 4 | import { AppInfo } from '@presentation/rest/config'; 5 | 6 | class HealthStatusApiResponse { 7 | @Property() 8 | readonly status: string; 9 | 10 | @Property() 11 | readonly message: string; 12 | 13 | @Property() 14 | @Default(AppInfo.APP_VERSION) 15 | readonly appVersion: string = AppInfo.APP_VERSION; 16 | 17 | constructor(status: string, message: string) { 18 | this.status = status; 19 | this.message = message; 20 | } 21 | 22 | public static fromHealthStatusResponse(healthStatus: HealthStatusResponse): HealthStatusResponse { 23 | return new HealthStatusApiResponse(healthStatus.status, healthStatus.message); 24 | } 25 | } 26 | 27 | export { HealthStatusApiResponse }; 28 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Context, Get } from '@tsed/common'; 2 | import { Description, Returns, Status, Summary, Tags, Title } from '@tsed/schema'; 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { CheckHealthStatusRequest, CheckHealthStatusUseCase } from '@application/health'; 6 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 7 | import { AppConfig, AppInfo } from '@presentation/rest/config'; 8 | import { RestController } from '@presentation/rest/shared/rest-controller.decorator'; 9 | 10 | import { HealthStatusApiResponse } from './health-status.api-response'; 11 | 12 | @RestController('/healthz') 13 | @Tags({ name: 'Health', description: 'Status and health check' }) 14 | class HealthController { 15 | private checkHealthStatusUseCase: CheckHealthStatusUseCase; 16 | 17 | constructor(checkHealthStatusUseCase: CheckHealthStatusUseCase) { 18 | this.checkHealthStatusUseCase = checkHealthStatusUseCase; 19 | } 20 | 21 | @Get() 22 | @Title('Health') 23 | @Summary('Health check') 24 | @Description('Endpoint to check whether the application is healthy or unhealthy') 25 | @Returns(StatusCodes.OK, HealthStatusApiResponse) 26 | @Status(StatusCodes.OK, HealthStatusApiResponse) 27 | public async checkHealthStatus( 28 | @Context(AppConfig.TRIGGERED_BY_CONTEXT_KEY) triggeredBy: TriggeredBy 29 | ): Promise { 30 | return HealthStatusApiResponse.fromHealthStatusResponse( 31 | await this.checkHealthStatusUseCase.execute(CheckHealthStatusRequest.create(triggeredBy, AppInfo.APP_VERSION)) 32 | ); 33 | } 34 | } 35 | 36 | export { HealthController }; 37 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/users/user.api-response.ts: -------------------------------------------------------------------------------- 1 | import { Default, Email, Enum, Format, Property } from '@tsed/schema'; 2 | 3 | import { UserResponse } from '@application/users'; 4 | import { UserGenders, UserRoles } from '@domain/users'; 5 | 6 | class UserApiResponse { 7 | @Property() 8 | readonly uuid: string; 9 | 10 | @Enum(UserGenders) 11 | @Default(UserGenders.UNDEFINED) 12 | readonly gender: string; 13 | 14 | @Property() 15 | readonly firstName: string; 16 | 17 | @Property() 18 | readonly lastName: string; 19 | 20 | @Format('date') 21 | readonly birthDate: Date; 22 | 23 | @Property() 24 | readonly username: string; 25 | 26 | @Email() 27 | readonly email: string; 28 | 29 | @Property() 30 | readonly phoneNumber: string; 31 | 32 | @Property() 33 | readonly address: string; 34 | 35 | @Property() 36 | readonly profilePicUrl: string; 37 | 38 | @Property() 39 | readonly passwordHash: string; 40 | 41 | @Enum(UserRoles) 42 | @Default(UserRoles.USER) 43 | readonly roles: string[]; 44 | 45 | @Property() 46 | readonly verified: boolean; 47 | 48 | @Property() 49 | readonly enabled: boolean; 50 | 51 | constructor( 52 | uuid: string, 53 | gender: string, 54 | firstName: string, 55 | lastName: string, 56 | birthDate: Date, 57 | username: string, 58 | email: string, 59 | phoneNumber: string, 60 | address: string, 61 | profilePicUrl: string, 62 | passwordHash: string, 63 | roles: string[], 64 | verified: boolean, 65 | enabled: boolean 66 | ) { 67 | this.uuid = uuid; 68 | this.gender = gender; 69 | this.firstName = firstName; 70 | this.lastName = lastName; 71 | this.birthDate = birthDate; 72 | this.username = username; 73 | this.email = email; 74 | this.phoneNumber = phoneNumber; 75 | this.address = address; 76 | this.profilePicUrl = profilePicUrl; 77 | this.passwordHash = passwordHash; 78 | this.roles = roles; 79 | this.verified = verified; 80 | this.enabled = enabled; 81 | } 82 | 83 | public static fromUserResponse(user: UserResponse): UserApiResponse { 84 | return new UserApiResponse( 85 | user.uuid, 86 | user.gender, 87 | user.firstName, 88 | user.lastName, 89 | user.birthDate, 90 | user.username, 91 | user.email, 92 | user.phoneNumber, 93 | user.address, 94 | user.profilePicUrl, 95 | user.passwordHash, 96 | user.roles, 97 | user.verified, 98 | user.enabled 99 | ); 100 | } 101 | } 102 | 103 | export { UserApiResponse }; 104 | -------------------------------------------------------------------------------- /src/presentation/rest/controllers/users/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Context, Get, PathParams } from '@tsed/common'; 2 | import { Description, Returns, Status, Summary, Tags, Title } from '@tsed/schema'; 3 | import { StatusCodes } from 'http-status-codes'; 4 | 5 | import { FindUserRequest, FindUserUseCase } from '@application/users/find'; 6 | import { SearchAllUsersRequest, SearchAllUsersUseCase } from '@application/users/search-all'; 7 | import { TriggeredBy } from '@domain/shared/entities/triggered-by'; 8 | import { UserRoles } from '@domain/users'; 9 | import { AppConfig } from '@presentation/rest/config'; 10 | import { RestController } from '@presentation/rest/shared/rest-controller.decorator'; 11 | import { WithAuth } from '@presentation/rest/shared/with-auth.decorator'; 12 | 13 | import { UserApiResponse } from './user.api-response'; 14 | 15 | @RestController('/users') 16 | @Tags({ name: 'User', description: 'User management' }) 17 | class UserController { 18 | private findUserUseCase: FindUserUseCase; 19 | 20 | private searchAllUsersUseCase: SearchAllUsersUseCase; 21 | 22 | constructor(findUserUseCase: FindUserUseCase, searchAllUsersUseCase: SearchAllUsersUseCase) { 23 | this.findUserUseCase = findUserUseCase; 24 | this.searchAllUsersUseCase = searchAllUsersUseCase; 25 | } 26 | 27 | @Get() 28 | @WithAuth({ roles: [UserRoles.ADMIN] }) 29 | @Title('Get all users') 30 | @Summary('Obtain all users') 31 | @Description('Endpoint to obtain all users') 32 | @Returns(StatusCodes.OK, Array).Of(UserApiResponse) 33 | @Status(StatusCodes.OK, Array).Of(UserApiResponse) 34 | public async searchAllUsers( 35 | @Context(AppConfig.TRIGGERED_BY_CONTEXT_KEY) triggeredBy: TriggeredBy 36 | ): Promise { 37 | const userResponses = await this.searchAllUsersUseCase.execute(SearchAllUsersRequest.create(triggeredBy)); 38 | return userResponses.map(UserApiResponse.fromUserResponse); 39 | } 40 | 41 | @Get('/:uuid') 42 | @WithAuth({ roles: [UserRoles.ADMIN] }) 43 | @Title('Get user by UUID') 44 | @Summary('Obtain user by UUID') 45 | @Description('Endpoint to obtain a user by UUID') 46 | @Returns(StatusCodes.OK, UserApiResponse) 47 | @Status(StatusCodes.OK, UserApiResponse) 48 | public async findUser( 49 | @Context(AppConfig.TRIGGERED_BY_CONTEXT_KEY) triggeredBy: TriggeredBy, 50 | @PathParams('uuid') uuid: string 51 | ): Promise { 52 | const userResponse = await this.findUserUseCase.execute(FindUserRequest.create(triggeredBy, uuid)); 53 | return UserApiResponse.fromUserResponse(userResponse); 54 | } 55 | } 56 | 57 | export { UserController }; 58 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/api.exception.ts: -------------------------------------------------------------------------------- 1 | class ApiException extends Error { 2 | public status: number; 3 | 4 | public code: string; 5 | 6 | public message: string; 7 | 8 | constructor(status: number, code: string, message: string) { 9 | super(message); 10 | this.name = new.target.name; 11 | this.status = status; 12 | this.code = code; 13 | this.message = message; 14 | } 15 | } 16 | 17 | export { ApiException }; 18 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/bad-request.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class BadRequestException extends ApiException { 7 | constructor(message: string) { 8 | super(StatusCodes.BAD_REQUEST, 'bad_request', `${emoji.get('-1')} ${message}.`); 9 | } 10 | } 11 | 12 | export { BadRequestException }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/exception.api-response.ts: -------------------------------------------------------------------------------- 1 | import { Exception as TsEdException } from '@tsed/exceptions'; 2 | import { Default, Enum, Integer, Property } from '@tsed/schema'; 3 | import { ReasonPhrases, StatusCodes } from 'http-status-codes'; 4 | import * as emoji from 'node-emoji'; 5 | 6 | import { AppInfo } from '@presentation/rest/config'; 7 | 8 | import { ApiException } from './api.exception'; 9 | 10 | class ExceptionApiResponse { 11 | @Integer() 12 | @Enum(StatusCodes) 13 | @Default(StatusCodes.IM_A_TEAPOT) 14 | public status: number; 15 | 16 | @Property() 17 | @Default('im_a_teapot') 18 | public code: string; 19 | 20 | @Property() 21 | @Default(ReasonPhrases.IM_A_TEAPOT) 22 | public message: string; 23 | 24 | @Property() 25 | @Default(AppInfo.APP_VERSION) 26 | readonly appVersion: string = AppInfo.APP_VERSION; 27 | 28 | constructor(status: number, code: string, message: string) { 29 | this.status = status; 30 | this.code = code; 31 | this.message = message; 32 | } 33 | 34 | public static fromApiException(exception: ApiException): ExceptionApiResponse { 35 | return new ExceptionApiResponse(exception.status, exception.code.toLowerCase(), exception.message); 36 | } 37 | 38 | public static fromTsEdException(exception: TsEdException): ExceptionApiResponse { 39 | return new ExceptionApiResponse( 40 | exception.status, 41 | exception.name.toLowerCase(), 42 | `${emoji.get('warning')} ${exception.message}` 43 | ); 44 | } 45 | } 46 | 47 | export { ExceptionApiResponse }; 48 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/forbidden.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class ForbiddenException extends ApiException { 7 | constructor() { 8 | super(StatusCodes.FORBIDDEN, 'forbidden', `${emoji.get('no_entry_sign')} Forbidden.`); 9 | } 10 | } 11 | 12 | export { ForbiddenException }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api.exception'; 2 | export * from './bad-request.exception'; 3 | export * from './exception.api-response'; 4 | export * from './forbidden.exception'; 5 | export * from './internal-server-error.exception'; 6 | export * from './no-credentials-provided.exception'; 7 | export * from './path-not-found.exception'; 8 | export * from './resource-not-found.exception'; 9 | export * from './unauthorized.exception'; 10 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/internal-server-error.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class InternalServerErrorException extends ApiException { 7 | constructor() { 8 | super( 9 | StatusCodes.INTERNAL_SERVER_ERROR, 10 | 'unexpected_error', 11 | `${emoji.get('fire')} An unexpected error has occurred. Please contact the administrator.` 12 | ); 13 | } 14 | } 15 | 16 | export { InternalServerErrorException }; 17 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/no-credentials-provided.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class NoCredentialsProvidedException extends ApiException { 7 | constructor() { 8 | super(StatusCodes.BAD_REQUEST, 'no_credentials_provided', `${emoji.get('confused')} No credentials provided.`); 9 | } 10 | } 11 | 12 | export { NoCredentialsProvidedException }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/path-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class PathNotFoundException extends ApiException { 7 | constructor(method: string, path: string) { 8 | super( 9 | StatusCodes.NOT_FOUND, 10 | 'path_not_found', 11 | `${emoji.get('cry')} Can't find ${method.toUpperCase()} ${path} on this server.` 12 | ); 13 | } 14 | } 15 | 16 | export { PathNotFoundException }; 17 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/resource-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class ResourceNotFoundException extends ApiException { 7 | constructor(message: string) { 8 | super(StatusCodes.NOT_FOUND, 'resource_not_found', `${emoji.get('cry')} ${message}.`); 9 | } 10 | } 11 | 12 | export { ResourceNotFoundException }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/exceptions/unauthorized.exception.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import * as emoji from 'node-emoji'; 3 | 4 | import { ApiException } from './api.exception'; 5 | 6 | class UnauthorizedException extends ApiException { 7 | constructor() { 8 | super(StatusCodes.UNAUTHORIZED, 'unauthorized', `${emoji.get('ticket')} Failed to authenticate.`); 9 | } 10 | } 11 | 12 | export { UnauthorizedException }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/filters/error-handler.filter.ts: -------------------------------------------------------------------------------- 1 | import { Catch, ExceptionFilterMethods, PlatformContext, Res } from '@tsed/common'; 2 | import { Exception, Exception as TsEdException } from '@tsed/exceptions'; 3 | 4 | import { Logger } from '@domain/shared'; 5 | import { 6 | ApiException, 7 | BadRequestException, 8 | ExceptionApiResponse, 9 | InternalServerErrorException, 10 | ResourceNotFoundException, 11 | UnauthorizedException 12 | } from '@presentation/rest/exceptions'; 13 | 14 | @Catch(Exception) 15 | @Catch(Error) 16 | class HttpExceptionFilter implements ExceptionFilterMethods { 17 | public catch(error: Error, context: PlatformContext): void { 18 | const { response } = context; 19 | 20 | return this.getExceptionHandler(error)(response.raw, error); 21 | } 22 | 23 | private getExceptionHandler = (exception: Error): ((response: Res, error: Error) => void) => { 24 | const invalidParameterHandler = (response: Res, error: Error): void => { 25 | const badRequestException = new BadRequestException(error.message); 26 | response.status(badRequestException.status).send(ExceptionApiResponse.fromApiException(badRequestException)); 27 | }; 28 | 29 | const invalidCredentialsHandler = (response: Res, _error: Error): void => { 30 | const unauthorizedException = new UnauthorizedException(); 31 | response.status(unauthorizedException.status).send(ExceptionApiResponse.fromApiException(unauthorizedException)); 32 | }; 33 | 34 | const userNotFoundHandler = (response: Res, error: Error): void => { 35 | const resourceNotFoundException = new ResourceNotFoundException(error.message); 36 | response 37 | .status(resourceNotFoundException.status) 38 | .send(ExceptionApiResponse.fromApiException(resourceNotFoundException)); 39 | }; 40 | 41 | const invalidSessionHandler = (response: Res, _error: Error): void => { 42 | const unauthorizedException = new UnauthorizedException(); 43 | response.status(unauthorizedException.status).send(ExceptionApiResponse.fromApiException(unauthorizedException)); 44 | }; 45 | 46 | const defaultHandler = (response: Res, error: Error): void => { 47 | if (error instanceof ApiException) { 48 | response.status(error.status).send(ExceptionApiResponse.fromApiException(error)); 49 | } else if (error instanceof TsEdException) { 50 | response.status(error.status).send(ExceptionApiResponse.fromTsEdException(error)); 51 | } else { 52 | Logger.error(`[@ErrorHandler] ${this.constructor.name}.catch() threw the following error! --- ${error}`); 53 | const internalServerErrorException = new InternalServerErrorException(); 54 | response 55 | .status(internalServerErrorException.status) 56 | .send(ExceptionApiResponse.fromApiException(internalServerErrorException)); 57 | } 58 | }; 59 | 60 | const exceptionHandlers: { [exception: string]: (response: Res, error: Error) => void } = { 61 | InvalidParameterException: invalidParameterHandler, 62 | InvalidAuthenticationUsernameException: invalidCredentialsHandler, 63 | InvalidAuthenticationCredentialsException: invalidCredentialsHandler, 64 | UserNotExistsException: userNotFoundHandler, 65 | InvalidSessionException: invalidSessionHandler, 66 | DefaultException: defaultHandler 67 | }; 68 | 69 | return exceptionHandlers[exception.name || exception.constructor.name] || exceptionHandlers.DefaultException; 70 | }; 71 | } 72 | 73 | export { HttpExceptionFilter }; 74 | -------------------------------------------------------------------------------- /src/presentation/rest/filters/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-empty-file */ 2 | /* 3 | * If you want to use filters instead of middleware 4 | * to capture errors globally, you need to uncomment 5 | * the following lines. 6 | */ 7 | // export * from './error-handler.filter'; 8 | // export * from './not-found.filter'; 9 | -------------------------------------------------------------------------------- /src/presentation/rest/filters/not-found.filter.ts: -------------------------------------------------------------------------------- 1 | import { Catch, ExceptionFilterMethods, PlatformContext, ResourceNotFound } from '@tsed/common'; 2 | 3 | import { PathNotFoundException } from '@presentation/rest/exceptions'; 4 | 5 | @Catch(ResourceNotFound) 6 | class ResourceNotFoundFilter implements ExceptionFilterMethods { 7 | public catch(_exception: ResourceNotFound, context: PlatformContext): void { 8 | const { request } = context; 9 | throw new PathNotFoundException(request.method, request.url); 10 | } 11 | } 12 | 13 | export { ResourceNotFoundFilter }; 14 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/authentication.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware, MiddlewareMethods, Req, Res } from '@tsed/common'; 2 | 3 | import { ValidateSessionRequest, ValidateSessionUseCase } from '@application/sessions/validate'; 4 | import { ValidatedSessionResponse } from '@application/sessions/validate/validated-session.response'; 5 | import { TriggeredByUser } from '@domain/shared/entities/triggered-by'; 6 | import { UserRoles } from '@domain/users'; 7 | import { Authentication } from '@infrastructure/shared/authentication/authentication'; 8 | import { AuthenticationUtils } from '@infrastructure/shared/authentication/authentication-utils'; 9 | import { AppConfig } from '@presentation/rest/config'; 10 | import { ForbiddenException } from '@presentation/rest/exceptions'; 11 | import { RequestUtils } from '@presentation/rest/shared/request.utils'; 12 | import { ResponseUtils } from '@presentation/rest/shared/response.utils'; 13 | 14 | @Middleware() 15 | class AuthenticationMiddleware implements MiddlewareMethods { 16 | private validateSessionUseCase: ValidateSessionUseCase; 17 | 18 | constructor(validateSessionUseCase: ValidateSessionUseCase) { 19 | this.validateSessionUseCase = validateSessionUseCase; 20 | } 21 | 22 | public async use(@Req() request: Req, @Res() response: Res, @Context() context: Context): Promise { 23 | const accessTokenString = RequestUtils.getAccessToken(request); 24 | const refreshTokenString = RequestUtils.getRefreshToken(request); 25 | 26 | const validateSessionRequest = ValidateSessionRequest.create( 27 | context.get(AppConfig.TRIGGERED_BY_CONTEXT_KEY), 28 | accessTokenString, 29 | refreshTokenString 30 | ); 31 | const validatedSessionResponse = await this.validateSessionUseCase.execute(validateSessionRequest); 32 | 33 | this.ensureUserHasPrivileges(context, validatedSessionResponse); 34 | 35 | this.attachAccessAndRefreshTokensIfSessionWasRefreshed(response, validatedSessionResponse); 36 | 37 | this.attachMetadataToContext(context, validatedSessionResponse); 38 | } 39 | 40 | private ensureUserHasPrivileges(context: Context, validatedSessionResponse: ValidatedSessionResponse): void { 41 | const userRoles = validatedSessionResponse.accessToken?.roles.map(role => role.value.toLowerCase()) ?? []; 42 | const { roles: allowedRoles = [] } = context.endpoint.get(AuthenticationMiddleware) || {}; 43 | 44 | const userHasPrivileges = 45 | allowedRoles.length === 0 || allowedRoles.some((role: UserRoles) => userRoles.includes(role.toLowerCase())); 46 | 47 | if (!userHasPrivileges) { 48 | throw new ForbiddenException(); 49 | } 50 | } 51 | 52 | private attachAccessAndRefreshTokensIfSessionWasRefreshed( 53 | response: Res, 54 | validatedSessionResponse: ValidatedSessionResponse 55 | ): void { 56 | if (validatedSessionResponse.wasRefreshed) { 57 | const { accessToken, refreshToken } = validatedSessionResponse; 58 | 59 | ResponseUtils.attachAccessAndRefreshTokens(response, accessToken, refreshToken); 60 | } 61 | } 62 | 63 | private attachMetadataToContext(context: Context, validatedSessionResponse: ValidatedSessionResponse): void { 64 | const { accessToken } = validatedSessionResponse; 65 | 66 | if (accessToken != null) { 67 | const userRoles = accessToken.roles.map(role => role.value); 68 | 69 | const authentication = Authentication.create( 70 | accessToken.userUuid.value, 71 | accessToken.username.value, 72 | accessToken.email.value, 73 | userRoles 74 | ); 75 | context.set(AppConfig.AUTHENTICATION_CONTEXT_KEY, authentication); 76 | AuthenticationUtils.setAuthentication(authentication); 77 | 78 | const triggeredBy = new TriggeredByUser(accessToken.username.value, userRoles); 79 | context.set(AppConfig.TRIGGERED_BY_CONTEXT_KEY, triggeredBy); 80 | } 81 | } 82 | } 83 | 84 | export { AuthenticationMiddleware }; 85 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/error-handler.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Err, Middleware, MiddlewareMethods, Next, Req, Res } from '@tsed/common'; 2 | import { Exception as TsEdException } from '@tsed/exceptions'; 3 | 4 | import { Logger } from '@domain/shared'; 5 | import { 6 | ApiException, 7 | BadRequestException, 8 | ExceptionApiResponse, 9 | ResourceNotFoundException, 10 | UnauthorizedException 11 | } from '@presentation/rest/exceptions'; 12 | import { InternalServerErrorException } from '@presentation/rest/exceptions/internal-server-error.exception'; 13 | 14 | @Middleware() 15 | class ErrorHandlerMiddleware implements MiddlewareMethods { 16 | public use(@Err() error: Error, @Req() _request: Req, @Res() response: Res, @Next() _next: Next): void { 17 | return this.getExceptionHandler(error)(response, error); 18 | } 19 | 20 | private getExceptionHandler = (exception: Error): ((response: Res, error: Error) => void) => { 21 | const invalidParameterHandler = (response: Res, error: Error): void => { 22 | const badRequestException = new BadRequestException(error.message); 23 | response.status(badRequestException.status).send(ExceptionApiResponse.fromApiException(badRequestException)); 24 | }; 25 | 26 | const invalidCredentialsHandler = (response: Res, _error: Error): void => { 27 | const unauthorizedException = new UnauthorizedException(); 28 | response.status(unauthorizedException.status).send(ExceptionApiResponse.fromApiException(unauthorizedException)); 29 | }; 30 | 31 | const userNotFoundHandler = (response: Res, error: Error): void => { 32 | const resourceNotFoundException = new ResourceNotFoundException(error.message); 33 | response 34 | .status(resourceNotFoundException.status) 35 | .send(ExceptionApiResponse.fromApiException(resourceNotFoundException)); 36 | }; 37 | 38 | const invalidSessionHandler = (response: Res, _error: Error): void => { 39 | const unauthorizedException = new UnauthorizedException(); 40 | response.status(unauthorizedException.status).send(ExceptionApiResponse.fromApiException(unauthorizedException)); 41 | }; 42 | 43 | const defaultHandler = (response: Res, error: Error): void => { 44 | if (error instanceof ApiException) { 45 | response.status(error.status).send(ExceptionApiResponse.fromApiException(error)); 46 | } else if (error instanceof TsEdException) { 47 | response.status(error.status).send(ExceptionApiResponse.fromTsEdException(error)); 48 | } else { 49 | Logger.error(`[@ErrorHandler] ${this.constructor.name}.catch() threw the following error! --- ${error}`); 50 | const internalServerErrorException = new InternalServerErrorException(); 51 | response 52 | .status(internalServerErrorException.status) 53 | .send(ExceptionApiResponse.fromApiException(internalServerErrorException)); 54 | } 55 | }; 56 | 57 | const exceptionHandlers: { [exception: string]: (response: Res, error: Error) => void } = { 58 | InvalidParameterException: invalidParameterHandler, 59 | InvalidAuthenticationUsernameException: invalidCredentialsHandler, 60 | InvalidAuthenticationCredentialsException: invalidCredentialsHandler, 61 | UserNotExistsException: userNotFoundHandler, 62 | InvalidSessionException: invalidSessionHandler, 63 | DefaultException: defaultHandler 64 | }; 65 | 66 | return exceptionHandlers[exception.name || exception.constructor.name] || exceptionHandlers.DefaultException; 67 | }; 68 | } 69 | 70 | export { ErrorHandlerMiddleware }; 71 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authentication.middleware'; 2 | export * from './error-handler.middleware'; 3 | export * from './logger.middleware'; 4 | export * from './metadata.middleware'; 5 | export * from './not-found.middleware'; 6 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware, MiddlewareMethods, Req, Res } from '@tsed/common'; 2 | 3 | import { GlobalConfig } from '@infrastructure/shared/config'; 4 | import { PINO_LOGGER } from '@infrastructure/shared/logger/pino-logger'; 5 | 6 | @Middleware() 7 | class LoggerMiddleware implements MiddlewareMethods { 8 | public use(@Req() request: Req, @Res() response: Res, @Context() context: Context): void { 9 | const loggerMiddleware = PINO_LOGGER.createPinoHttpMiddleware(); 10 | loggerMiddleware(request, response); 11 | context.set(GlobalConfig.PINO_LOGGER_KEY, request.log); 12 | } 13 | } 14 | 15 | export { LoggerMiddleware }; 16 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/metadata.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware, MiddlewareMethods, OnResponse, Req } from '@tsed/common'; 2 | 3 | import { Token, TokenProviderDomainService } from '@domain/sessions/tokens'; 4 | import { Nullable } from '@domain/shared'; 5 | import { TriggeredByAnonymous, TriggeredByUser } from '@domain/shared/entities/triggered-by'; 6 | import { Authentication } from '@infrastructure/shared/authentication'; 7 | import { AuthenticationUtils } from '@infrastructure/shared/authentication/authentication-utils'; 8 | import { AppConfig } from '@presentation/rest/config'; 9 | import { RequestUtils } from '@presentation/rest/shared/request.utils'; 10 | 11 | @Middleware() 12 | class MetadataMiddleware implements MiddlewareMethods, OnResponse { 13 | private tokenProviderDomainService: TokenProviderDomainService; 14 | 15 | constructor(tokenProviderDomainService: TokenProviderDomainService) { 16 | this.tokenProviderDomainService = tokenProviderDomainService; 17 | } 18 | 19 | public use(@Req() request: Req, @Context() context: Context): void { 20 | let triggeredBy = new TriggeredByAnonymous(); 21 | let authentication = Authentication.createEmpty(); 22 | 23 | const accessTokenString = RequestUtils.getAccessToken(request); 24 | const refreshTokenString = RequestUtils.getRefreshToken(request); 25 | const accessToken = accessTokenString ? this.tokenProviderDomainService.parseAccessToken(accessTokenString) : null; 26 | const refreshToken = refreshTokenString 27 | ? this.tokenProviderDomainService.parseRefreshToken(refreshTokenString) 28 | : null; 29 | const token: Nullable = accessToken || refreshToken; 30 | 31 | if (token) { 32 | const userRoles = token.roles.map(role => role.value); 33 | triggeredBy = new TriggeredByUser(token.username.value, userRoles); 34 | authentication = Authentication.create(token.userUuid.value, token.username.value, token.email.value, userRoles); 35 | } 36 | 37 | AuthenticationUtils.setAuthentication(authentication); 38 | context.set(AppConfig.AUTHENTICATION_CONTEXT_KEY, authentication); 39 | context.set(AppConfig.TRIGGERED_BY_CONTEXT_KEY, triggeredBy); 40 | } 41 | 42 | public $onResponse(): void { 43 | AuthenticationUtils.clearAuthentication(); 44 | } 45 | } 46 | 47 | export { MetadataMiddleware }; 48 | -------------------------------------------------------------------------------- /src/presentation/rest/middlewares/not-found.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Middleware, MiddlewareMethods, Next, Req, Res } from '@tsed/common'; 2 | 3 | import { PathNotFoundException } from '@presentation/rest/exceptions'; 4 | 5 | @Middleware() 6 | class NotFoundMiddleware implements MiddlewareMethods { 7 | public use(@Req() request: Req, @Res() _response: Res, @Next() next: Next): void { 8 | next(new PathNotFoundException(request.method, request.originalUrl)); 9 | } 10 | } 11 | 12 | export { NotFoundMiddleware }; 13 | -------------------------------------------------------------------------------- /src/presentation/rest/shared/request.utils.ts: -------------------------------------------------------------------------------- 1 | import { Req } from '@tsed/common'; 2 | 3 | import { Nullable } from '@domain/shared'; 4 | import { AppConfig } from '@presentation/rest/config'; 5 | 6 | const RequestUtils = { 7 | getAccessToken: (request: Req): Nullable => { 8 | const authorizationAccessTokenHeader = request.get(AppConfig.AUTHORIZATION_ACCESS_TOKEN_HEADER_NAME); 9 | const accessTokenHeader = request.get(AppConfig.ACCESS_TOKEN_HEADER_NAME); 10 | const accessTokenCookie = request.cookies[AppConfig.ACCESS_TOKEN_COOKIE_NAME]; 11 | 12 | return authorizationAccessTokenHeader 13 | ? authorizationAccessTokenHeader.replace('Bearer ', '') 14 | : accessTokenHeader || accessTokenCookie || null; 15 | }, 16 | 17 | getRefreshToken: (request: Req): Nullable => { 18 | const refreshTokenHeader = request.get(AppConfig.REFRESH_TOKEN_HEADER_NAME); 19 | const refreshTokenCookie = request.cookies[AppConfig.REFRESH_TOKEN_COOKIE_NAME]; 20 | 21 | return refreshTokenHeader || refreshTokenCookie || null; 22 | } 23 | }; 24 | 25 | export { RequestUtils }; 26 | -------------------------------------------------------------------------------- /src/presentation/rest/shared/response.utils.ts: -------------------------------------------------------------------------------- 1 | import { Res } from '@tsed/common'; 2 | 3 | import { AccessToken, RefreshToken } from '@domain/sessions/tokens'; 4 | import { Nullable } from '@domain/shared'; 5 | import { GlobalConfig } from '@infrastructure/shared/config'; 6 | import { AppConfig } from '@presentation/rest/config'; 7 | 8 | const ResponseUtils = { 9 | attachAccessAndRefreshTokens: ( 10 | response: Res, 11 | accessToken: Nullable, 12 | refreshToken?: Nullable 13 | ): void => { 14 | if (accessToken != null) { 15 | response.set(AppConfig.ACCESS_TOKEN_HEADER_NAME, accessToken.value); 16 | 17 | response.cookie(AppConfig.ACCESS_TOKEN_COOKIE_NAME, accessToken.value, { 18 | httpOnly: true, 19 | expires: accessToken.expiresAt.value, 20 | secure: GlobalConfig.IS_PRODUCTION 21 | }); 22 | } 23 | 24 | if (refreshToken != null) { 25 | response.set(AppConfig.REFRESH_TOKEN_HEADER_NAME, refreshToken.value); 26 | 27 | response.cookie(AppConfig.REFRESH_TOKEN_COOKIE_NAME, refreshToken.value, { 28 | httpOnly: true, 29 | expires: refreshToken.expiresAt.value, 30 | secure: GlobalConfig.IS_PRODUCTION 31 | }); 32 | } 33 | }, 34 | 35 | clearAccessAndRefreshTokens: (response: Res): void => { 36 | response.set(AppConfig.ACCESS_TOKEN_HEADER_NAME, 'deleted'); 37 | response.clearCookie(AppConfig.ACCESS_TOKEN_COOKIE_NAME); 38 | 39 | response.set(AppConfig.REFRESH_TOKEN_HEADER_NAME, 'deleted'); 40 | response.clearCookie(AppConfig.REFRESH_TOKEN_COOKIE_NAME); 41 | } 42 | }; 43 | 44 | export { ResponseUtils }; 45 | -------------------------------------------------------------------------------- /src/presentation/rest/shared/rest-controller.decorator.ts: -------------------------------------------------------------------------------- 1 | import { useDecorators } from '@tsed/core'; 2 | import { Controller } from '@tsed/di'; 3 | import { ContentType } from '@tsed/schema'; 4 | 5 | const RestController = (options: any): ClassDecorator => 6 | useDecorators(Controller(options), ContentType('application/json')); 7 | 8 | export { RestController }; 9 | -------------------------------------------------------------------------------- /src/presentation/rest/shared/with-auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { UseAuth } from '@tsed/common'; 2 | import { useDecorators } from '@tsed/core'; 3 | import { Returns, Security } from '@tsed/schema'; 4 | import { StatusCodes } from 'http-status-codes'; 5 | 6 | import { ExceptionApiResponse } from '@presentation/rest/exceptions'; 7 | import { AuthenticationMiddleware } from '@presentation/rest/middlewares'; 8 | 9 | interface AuthOptions extends Record { 10 | roles?: string[]; 11 | } 12 | 13 | const WithAuth = (options: AuthOptions = {}): any => { 14 | return useDecorators( 15 | UseAuth(AuthenticationMiddleware, options), 16 | Security('Bearer'), 17 | Returns(StatusCodes.UNAUTHORIZED, ExceptionApiResponse), 18 | Returns(StatusCodes.FORBIDDEN, ExceptionApiResponse) 19 | ); 20 | }; 21 | 22 | export { WithAuth }; 23 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import 'jest-extended'; 3 | -------------------------------------------------------------------------------- /test/e2e/health/check-health-status.e2e.ts: -------------------------------------------------------------------------------- 1 | import { SuperTestRequest, TestServer } from '@test/e2e/shared'; 2 | 3 | describe('Testing health check controller/entrypoint', () => { 4 | let request: SuperTestRequest; 5 | 6 | beforeAll(async () => { 7 | await TestServer.bootstrap(); 8 | request = TestServer.getSuperTestRequest(); 9 | }); 10 | afterAll(async () => { 11 | await TestServer.reset(); 12 | }); 13 | 14 | describe('[GET] /api/healthz', () => { 15 | it('should return 200 OK', async () => { 16 | return request.get('/api/healthz').expect(200); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/e2e/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-server'; 2 | -------------------------------------------------------------------------------- /test/e2e/shared/test-server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { PlatformTest } from '@tsed/common'; 3 | import { IORedisTest } from '@tsed/ioredis'; 4 | import IORedis from 'ioredis'; 5 | import SuperTest from 'supertest'; 6 | 7 | import { bootstrap } from '@infrastructure/shared'; 8 | import { Server } from '@presentation/rest/server'; 9 | 10 | type SuperTestRequest = SuperTest.SuperTest | any; 11 | 12 | const mockExternalDependencies = (): void => { 13 | jest.spyOn(PrismaClient.prototype, '$connect').mockImplementation(() => Promise.resolve()); 14 | jest.spyOn(IORedis.prototype, 'connect').mockImplementation(() => Promise.resolve()); 15 | }; 16 | 17 | const TestServer = { 18 | bootstrap: async (): Promise => { 19 | mockExternalDependencies(); 20 | await bootstrap(); 21 | IORedisTest.create(); 22 | return PlatformTest.bootstrap(Server, { ...(await Server.getConfiguration()) })(); 23 | }, 24 | 25 | getSuperTestRequest: (): SuperTestRequest => { 26 | return SuperTest.agent(PlatformTest.callback()); 27 | }, 28 | reset: async (): Promise => { 29 | IORedisTest.reset(); 30 | PlatformTest.reset(); 31 | } 32 | }; 33 | 34 | export { SuperTestRequest, TestServer }; 35 | -------------------------------------------------------------------------------- /test/integration/health/check-health-status.int.ts: -------------------------------------------------------------------------------- 1 | import { CheckHealthStatusRequest, CheckHealthStatusUseCase, HealthStatusResponse } from '@application/health'; 2 | import { TriggeredByAnonymous } from '@domain/shared/entities/triggered-by'; 3 | import { AppInfo } from '@presentation/rest/config'; 4 | 5 | describe('Testing health check use case', () => { 6 | it('should return ALIVE health status', () => { 7 | const checkHealthStatusUseCase = new CheckHealthStatusUseCase(); 8 | return expect( 9 | checkHealthStatusUseCase.execute(new CheckHealthStatusRequest(new TriggeredByAnonymous(), AppInfo.APP_VERSION)) 10 | ).resolves.toEqual(HealthStatusResponse.create('ALIVE', '🚀 To infinity and beyond!', AppInfo.APP_VERSION)); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/jest.mocks.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino'; 2 | 3 | // Mock to avoid open handlers 4 | jest.mock('@tsed/swagger', () => ({ 5 | __esModule: true, 6 | default: 'mockedDefaultExport', 7 | namedExport: jest.fn() 8 | })); 9 | 10 | // Mock pino-http and pino 11 | const mockLogger: Partial = { 12 | child: jest.fn().mockReturnThis(), 13 | fatal: jest.fn(), 14 | error: jest.fn(), 15 | warn: jest.fn(), 16 | info: jest.fn(), 17 | debug: jest.fn(), 18 | trace: jest.fn() 19 | }; 20 | 21 | jest.mock('pino-http', () => { 22 | return jest.fn().mockImplementation(() => (): any => { 23 | return { 24 | logger: mockLogger 25 | }; 26 | }); 27 | }); 28 | 29 | const mockPino = jest.fn((): Logger => mockLogger as Logger) as any; 30 | mockPino.transport = jest.fn(); 31 | mockPino.destination = jest.fn(); 32 | jest.mock('pino', () => mockPino); 33 | -------------------------------------------------------------------------------- /test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import './jest.mocks'; 3 | -------------------------------------------------------------------------------- /test/unit/healthcheck/health-status-response.unit.ts: -------------------------------------------------------------------------------- 1 | import { HealthStatusResponse } from '@application/health'; 2 | import { HealthStatus } from '@domain/health'; 3 | import { AppInfo } from '@presentation/rest/config'; 4 | 5 | describe('Testing HealthStatusResponse generation', () => { 6 | it('should return a valid HealthStatusResponse from domain model', () => { 7 | return expect( 8 | HealthStatusResponse.fromDomainModel(new HealthStatus('ALIVE', '🚀 To infinity and beyond!', AppInfo.APP_VERSION)) 9 | ).toEqual(HealthStatusResponse.create('ALIVE', '🚀 To infinity and beyond!', AppInfo.APP_VERSION)); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "inlineSourceMap": true 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": [ 9 | "src/**/*.unit.ts", 10 | "src/**/*.int.ts", 11 | "src/**/*.e2e.ts", 12 | "src/**/*.spec.ts", 13 | "src/**/*.test.ts", 14 | "test/**/*.unit.ts", 15 | "test/**/*.int.ts", 16 | "test/**/*.e2e.ts", 17 | "test/**/*.spec.ts", 18 | "test/**/*.test.ts", 19 | "node_modules", 20 | "dist" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Project options 4 | "allowJs": true, // Allow JavaScript files to be imported inside your project, instead of just .ts and .tsx files 5 | "declaration": false, // Generate .d.ts files for every TypeScript or JavaScript file inside your project 6 | "lib": ["es2019", "es2020", "es2021", "esnext"], // Specifies which default set of type definitions to use ("DOM", "ES6", etc) 7 | "module": "commonjs", // Sets the module system for the program. "CommonJS" for node projects. 8 | "noEmit": false, // Do not emit compiler output files like JavaScript source code, source-maps or declarations. 9 | "outDir": "./dist", // .js (as well as .d.ts, .js.map, etc.) files will be emitted into this directory 10 | "sourceMap": true, // Enables the generation of sourcemap files 11 | "target": "es2019", // Target environment 12 | 13 | // Module resolution 14 | "baseUrl": "./", // Sets a base directory to resolve non-absolute module names 15 | "esModuleInterop": true, // fixes some issues TS originally had with the ES6 spec where TypeScript treats CommonJS/AMD/UMD modules similar to ES6 module 16 | "moduleResolution": "node", // Pretty much always node for modern JS. Other option is "classic" 17 | "paths": { 18 | "@application/*": ["src/application/*"], 19 | "@domain/*": ["src/domain/*"], 20 | "@infrastructure/*": ["src/infrastructure/*"], 21 | "@presentation/*": ["src/presentation/*"], 22 | "@test/*": ["test/*"], 23 | "@/*": ["src/*"] 24 | }, // A series of entries which re-map imports to lookup locations relative to the baseUrl 25 | "typeRoots": ["../node_modules/@types", "node_modules/@types", "src/types"], 26 | 27 | // Advanced 28 | "forceConsistentCasingInFileNames": true, // TypeScript will issue an error if a program tries to include a file by a casing different from the casing on disk 29 | "listEmittedFiles": false, // Print names of generated files part of the compilation 30 | "listFiles": false, // Print names of files part of the compilation 31 | "resolveJsonModule": true, // Allows importing modules with a ‘.json’ extension, which is a common practice in node projects 32 | "skipLibCheck": true, // Skip type checking of declaration files 33 | "traceResolution": false, // Report module resolution log messages 34 | 35 | // Experimental 36 | "experimentalDecorators": true, // Enables experimental support for decorators 37 | "emitDecoratorMetadata": true, // Enables experimental support for emitting type metadata for decorators which works with the module 38 | 39 | // Strict checks 40 | "strict": true, // Enable all strict type-checking options 41 | // "allowUnreachableCode": false, // Pick up dead code paths 42 | // "alwaysStrict": true, // Parse in strict mode and emit "use strict" for each source file 43 | // "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type 44 | // "noImplicitThis": true, // Raise error on 'this' expressions with an implied 'any' type 45 | // "strictFunctionTypes": true, // Enable strict checking of function types 46 | // "strictNullChecks": true, // Enable strict null checks 47 | "strictPropertyInitialization": false, // Enable strict checking of property initialization in classes 48 | 49 | // Linter Checks 50 | "noFallthroughCasesInSwitch": true, // Report errors for fallthrough cases in switch statement 51 | "noImplicitReturns": true, // Report error when not all code paths in function return a value 52 | "noUnusedLocals": true, // Report errors on unused local variables 53 | "noUnusedParameters": true, // Report errors on unused parameters 54 | "pretty": true // Stylize errors and messages using color and context 55 | }, 56 | "exclude": ["node_modules", "dist"], // Specifies an array of filenames or patterns that should be skipped when resolving include 57 | "compileOnSave": false // Compile on save 58 | } 59 | --------------------------------------------------------------------------------