├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── README.md ├── api.rest ├── bin ├── install.sh └── start.sh ├── commitlint.config.js ├── docker-compose.yaml ├── package.json ├── project-tree.py ├── public └── empty.txt ├── src ├── apps │ ├── auth │ │ ├── controllers │ │ │ ├── auth.controller.ts │ │ │ ├── index.ts │ │ │ └── otp.controller.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── _plugins │ │ │ │ ├── attemp-limiting.plugin.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── otp.model.ts │ │ ├── repositories │ │ │ ├── index.ts │ │ │ └── otp.repo.ts │ │ ├── routes │ │ │ ├── auth.routes.ts │ │ │ ├── index.ts │ │ │ └── otp.routes.ts │ │ ├── services │ │ │ ├── auth.service.ts │ │ │ ├── index.ts │ │ │ └── otp.service.ts │ │ ├── types │ │ │ ├── index.ts │ │ │ └── otp.ts │ │ └── validators │ │ │ ├── auth.ts │ │ │ └── index.ts │ ├── index.ts │ ├── starter │ │ ├── controllers │ │ │ ├── app.controller.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── routes │ │ │ ├── app.routes.ts │ │ │ └── index.ts │ └── users │ │ ├── controllers │ │ ├── index.ts │ │ └── user.controller.ts │ │ ├── index.ts │ │ ├── models │ │ ├── index.ts │ │ └── user.model.ts │ │ ├── repositories │ │ ├── index.ts │ │ └── user.repo.ts │ │ ├── routes │ │ ├── index.ts │ │ └── user.routes.ts │ │ ├── services │ │ ├── index.ts │ │ └── user.service.ts │ │ ├── types │ │ ├── index.ts │ │ └── user.ts │ │ └── validators │ │ ├── index.ts │ │ └── user.ts ├── common │ ├── global-router │ │ └── index.ts │ ├── shared │ │ ├── index.ts │ │ ├── middlewares │ │ │ ├── attach-user-to-context.ts │ │ │ ├── authenticate-req-with-user-attach.ts │ │ │ ├── authenticate-request.ts │ │ │ ├── bruteforce.ts │ │ │ ├── client-authentication.ts │ │ │ ├── index.ts │ │ │ ├── rate-limiter.ts │ │ │ └── validate.ts │ │ ├── services │ │ │ ├── async-localstorage.service.ts │ │ │ ├── index.ts │ │ │ ├── jwt.service.ts │ │ │ ├── logger.service.ts │ │ │ ├── mail │ │ │ │ ├── index.ts │ │ │ │ ├── mail.service.ts │ │ │ │ └── mail.service.utility.ts │ │ │ └── view.service.ts │ │ ├── types │ │ │ ├── express.d.ts │ │ │ ├── index.ts │ │ │ └── service-response.ts │ │ └── utils │ │ │ ├── handlers │ │ │ ├── api-reponse.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ ├── global.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notfound.ts │ │ │ │ └── response.ts │ │ │ ├── index.ts │ │ │ └── res │ │ │ │ └── index.ts │ │ │ └── index.ts │ └── templates │ │ └── mail │ │ └── welcome.html ├── core │ ├── config │ │ └── index.ts │ ├── constants │ │ └── index.ts │ ├── engine │ │ ├── base │ │ │ ├── _models │ │ │ │ ├── _plugins │ │ │ │ │ ├── audit-trail.plugin.ts │ │ │ │ │ ├── history.plugin.ts │ │ │ │ │ ├── index.plugin.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── soft-delete.plugin.ts │ │ │ │ │ └── versioning.plugin.ts │ │ │ │ ├── base.model.ts │ │ │ │ └── index.ts │ │ │ ├── _repositories │ │ │ │ ├── base.repo.ts │ │ │ │ └── index.ts │ │ │ ├── _services │ │ │ │ ├── base.service.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ └── index.ts │ └── framework │ │ ├── database │ │ ├── index.ts │ │ ├── mongoose │ │ │ ├── db.ts │ │ │ └── index.ts │ │ └── redis │ │ │ ├── index.ts │ │ │ └── redis.ts │ │ ├── index.ts │ │ ├── session-flash │ │ └── index.ts │ │ ├── storage │ │ ├── index.ts │ │ └── minio │ │ │ ├── index.ts │ │ │ └── minio.ts │ │ ├── view-engine │ │ ├── ejs.ts │ │ ├── handlebars.ts │ │ ├── index.ts │ │ ├── nunjucks.ts │ │ └── pug.ts │ │ └── webserver │ │ ├── express.ts │ │ └── index.ts ├── helpers │ ├── db-connection-test.ts │ ├── generator.ts │ ├── index.ts │ ├── init-services.ts │ ├── list-routes.ts │ ├── minio-test.ts │ ├── redis-test.ts │ ├── string.ts │ └── time.ts └── server.ts ├── tailwind.config.js ├── todo.txt ├── tsconfig.json └── views └── index.ejs /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Engine 2 | APP_NAME="Node-Typescript-Starter" 3 | PORT=9095 4 | ENABLE_CLIENT_AUTH=true 5 | 6 | # Client authentication 7 | BASIC_AUTH_USER=admin 8 | BASIC_AUTH_PASS=secret 9 | 10 | # JWT Tokens 11 | ACCESS_TOKEN_SECRET=your-access-token-secret 12 | ACCESS_TOKEN_EXPIRE_TIME=1h # Adjust as needed 13 | REFRESH_TOKEN_SECRET=your-refresh-token-secret 14 | REFRESH_TOKEN_EXPIRE_TIME=7d # Adjust as needed 15 | TOKEN_ISSUER=your-issuer 16 | 17 | # Database 18 | DB_URI=mongodb://mongo:27017 19 | DB_NAME=mydatabase 20 | MONGO_CLIENT_PORT=9005 21 | 22 | # Cache 23 | REDIS_HOST=redis 24 | REDIS_SERVER_PORT=9079 25 | REDIS_TOKEN_EXPIRE_TIME=31536000 # 1 year in seconds (validity for refresh token) 26 | REDIS_BLACKLIST_EXPIRE_TIME=2592000 # 1 month in seconds 27 | 28 | # MinIO 29 | MINIO_ENDPOINT=minio 30 | MINIO_ACCESS_KEY=minio-access-key 31 | MINIO_SECRET_KEY=minio-secret-key 32 | MINIO_API_PORT=9500 33 | MINIO_CONSOLE_PORT=9050 34 | 35 | # Maildev 36 | MAILDEV_HOST=maildev 37 | MAILDEV_PORT=1025 38 | MAILDEV_SMTP=9025 39 | MAILDEV_WEBAPP_PORT=9080 40 | 41 | # SMTP (for production) 42 | SMTP_HOST=smtp.example.com 43 | SMTP_PORT=587 44 | SMTP_USER=your-smtp-username 45 | SMTP_PASS=your-smtp-password 46 | 47 | # Mail Senders 48 | FROM_EMAIL=no-reply@myapp.com 49 | FROM_NAME="Your Service Name" 50 | 51 | # Rate Limiting 52 | RATE_LIMIT_WINDOW_MS=900000 # 15 minutes in milliseconds 53 | RATE_LIMIT_MAX=100 # 100 requests per windowMs 54 | 55 | # Bruteforce 56 | BRUTE_FORCE_FREE_RETRIES=5 57 | BRUTE_FORCE_MIN_WAIT=300000 # 5 minutes in milliseconds 58 | BRUTE_FORCE_MAX_WAIT=3600000 # 1 hour in milliseconds 59 | BRUTE_FORCE_LIFETIME=86400 # 1 day in seconds 60 | 61 | # Bcrypt 62 | BCRYPT_SALT_ROUNDS=10 63 | 64 | # Session 65 | SESSION_SESSION_SECRET="mysessionsecret" 66 | 67 | #View engine 68 | VIEW_ENGINE=ejs 69 | 70 | #OTP 71 | OTP_LENGTH=6 72 | OTP_EXPIRATION=15 73 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2016, 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint", "prettier"], 17 | "rules": { 18 | "prettier/prettier": "error", 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { "argsIgnorePattern": "^_" } 22 | ], 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/ban-types": [ 26 | "error", 27 | { 28 | "types": { 29 | "{}": false 30 | } 31 | } 32 | ], 33 | "@typescript-eslint/ban-ts-comment": "warn" 34 | }, 35 | "ignorePatterns": ["node_modules/", "dist/", "build/"] 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | package-lock.json 4 | logs -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": "*.pug", 10 | "options": { 11 | "parser": "pug" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node.js base image 2 | FROM node:18 3 | 4 | # Set the working directory in the container 5 | WORKDIR /usr/src/app 6 | 7 | # Copy the package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install application dependencies 11 | RUN npm install 12 | 13 | # Copy the rest of the application 14 | COPY . . 15 | 16 | # Build the TypeScript project 17 | RUN npm run build 18 | 19 | # Expose the application port 20 | EXPOSE $PORT 21 | 22 | # Define the command to run based on the environment 23 | CMD ["sh", "-c", "if [ \"$NODE_ENV\" = \"production\" ]; then npm run start:prod; else npm start; fi"] 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js TypeScript Starter Project 2 | 3 | This project is a simple and lightweight Node.js boilerplate using TypeScript. It includes Docker configurations to run the application in both development and production modes, along with enhanced security features such as rate limiting and brute force protection. 4 | 5 | --- 6 | 7 | 🛑🛑🛑 Check our latest complete boilerplate for NodeTs [Node Typescript Wizard](https://github.com/fless-lab/ntw-init) 8 | 9 | ## Table of Contents 10 | 11 | 1. [Prerequisites](#prerequisites) 12 | 2. [Installation](#installation) 13 | 3. [Running the Application](#running-the-application) 14 | 4. [Project Structure](#project-structure) 15 | 5. [Scripts Explanation](#scripts-explanation) 16 | 6. [Environment Variables](#environment-variables) 17 | 7. [Docker Configuration](#docker-configuration) 18 | 8. [Security Features](#security-features) 19 | 9. [Linting and Formatting](#linting-and-formatting) 20 | 10. [Commit Message Guidelines](#commit-message-guidelines) 21 | 11. [Accessing Services](#accessing-services) 22 | 12. [Contributing](#contributing) 23 | 24 | ## Prerequisites 25 | 26 | Ensure you have the following installed on your system: 27 | 28 | - Node.js (version 18 or above) 29 | - Docker 30 | - Docker Compose 31 | 32 | ## Installation 33 | 34 | To set up the project, follow these steps: 35 | 36 | 1. **Clone the repository**: 37 | ```sh 38 | git clone https://github.com/your-username/node-ts-starter.git 39 | cd node-ts-starter 40 | ``` 41 | 42 | 2. **Run the installation script**: 43 | ```sh 44 | bash bin/install.sh 45 | ``` 46 | 47 | This script will: 48 | - Copy the `.env.example` file to `.env`. 49 | - Install the necessary npm dependencies. 50 | 51 | ## Running the Application 52 | 53 | You can run the application in either development or production mode. 54 | 55 | ### Development Mode 56 | 57 | To run the application in development mode: 58 | ```sh 59 | bash bin/start.sh 60 | ``` 61 | 62 | ### Production Mode 63 | 64 | To run the application in production mode: 65 | ```sh 66 | bash bin/start.sh --prod 67 | ``` 68 | 69 | ## Project Structure 70 | 71 | Here is an overview of the project's structure: 72 | 73 | ``` 74 | /home/raouf/workspaces/personnal/projects/node-ts-starter 75 | ├── .eslintrc.json 76 | ├── .env 77 | ├── .env.example 78 | ├── .eslintignore 79 | ├── .prettierrc 80 | ├── bin 81 | │ ├── install.sh 82 | │ └── start.sh 83 | ├── Dockerfile 84 | ├── docker-compose.yaml 85 | ├── package.json 86 | ├── package-lock.json 87 | ├── README.md 88 | ├── src 89 | │ ├── app 90 | │ │ ├── controllers 91 | │ │ │ └── user.controller.ts 92 | │ │ ├── models 93 | │ │ │ └── user.model.ts 94 | │ │ ├── repositories 95 | │ │ │ ├── base.repo.ts 96 | │ │ │ └── user.repo.ts 97 | │ │ ├── routes 98 | │ │ │ ├── routes.ts 99 | │ │ │ └── user.routes.ts 100 | │ │ ├── services 101 | │ │ │ ├── base.service.ts 102 | │ │ │ └── user.service.ts 103 | │ │ ├── templates 104 | │ │ │ ├── app 105 | │ │ │ │ └── presentation.html 106 | │ │ │ └── mail 107 | │ │ │ └── welcome.html 108 | │ │ ├── utils 109 | │ │ │ ├── handlers 110 | │ │ │ │ ├── error 111 | │ │ │ │ │ ├── global.ts 112 | │ │ │ │ │ ├── notfound.ts 113 | │ │ │ │ │ └── index.ts 114 | │ │ │ │ ├── res 115 | │ │ │ │ │ └── index.ts 116 | │ │ │ │ └── index.ts 117 | │ │ │ ├── middlewares 118 | │ │ │ │ ├── bruteforce.ts 119 | │ │ │ │ ├── client-authentication.ts 120 | │ │ │ │ ├── rate-limiter.ts 121 | │ │ │ │ ├── validate.ts 122 | │ │ │ │ └── index.ts 123 | │ │ │ ├── types 124 | │ │ │ │ ├── service-response.ts 125 | │ │ │ │ ├── user.ts 126 | │ │ │ │ └── index.ts 127 | │ │ │ └── validators 128 | │ │ │ ├── user.ts 129 | │ │ │ └── index.ts 130 | │ ├── config 131 | │ │ └── index.ts 132 | │ ├── constants 133 | │ │ └── index.ts 134 | │ ├── framework 135 | │ │ ├── database 136 | │ │ │ ├── mongoose 137 | │ │ │ │ └── db.ts 138 | │ │ │ ├── redis 139 | │ │ │ │ └── redis.ts 140 | │ │ │ └── index.ts 141 | │ │ ├── storage 142 | │ │ │ └── minio 143 | │ │ │ └── minio.ts 144 | │ │ ├── webserver 145 | │ │ │ └── express.ts 146 | │ │ └── index.ts 147 | │ ├── helpers 148 | │ │ ├── db-connection-test.ts 149 | │ │ ├── index.ts 150 | │ │ ├── init-services.ts 151 | │ │ ├── minio-test.ts 152 | │ │ ├── redis-test.ts 153 | │ │ ├── string.ts 154 | │ │ └── time.ts 155 | │ ├── server.ts 156 | │ └── index.ts 157 | ├── commitlint.config.js 158 | ├── tsconfig.json 159 | └── .prettierignore 160 | ``` 161 | 162 | ## Scripts Explanation 163 | 164 | ### `bin/install.sh` 165 | 166 | This script sets up the project by performing the following tasks: 167 | - Copies the `.env.example` file to `.env`, replacing any existing `.env` file. 168 | - Installs npm dependencies. 169 | 170 | ### `bin/start.sh` 171 | 172 | This script runs the application by performing the following tasks: 173 | - Checks if Docker and Docker Compose are installed. 174 | - Runs the `install.sh` script to ensure dependencies are installed. 175 | - Sets the `NODE_ENV` environment variable based on the provided argument (`--prod` for production). 176 | - Starts the Docker containers using Docker Compose. 177 | 178 | ## Dockerfile 179 | 180 | The Dockerfile defines how the Docker image is built. It includes steps for setting up the working directory, installing dependencies, copying the source code, building the TypeScript project, and defining the startup command. 181 | 182 | ## docker-compose.yml 183 | 184 | This file defines the Docker services for the application, including the application itself, MongoDB, Redis, MinIO, and Maildev. It uses environment variables from the `.env` file to configure the services. 185 | 186 | ## Environment Variables 187 | 188 | The `.env` file contains the environment variables required by the application. It is generated from the `.env.example` file during installation. Ensure the following variables are set: 189 | 190 | ```env 191 | # Engine 192 | PORT=9095 193 | ENABLE_CLIENT_AUTH=true 194 | 195 | # Client authentication 196 | BASIC_AUTH_USER=admin 197 | BASIC_AUTH_PASS=secret 198 | 199 | # Rate limiting 200 | RATE_LIMIT_WINDOW_MS=900000 201 | RATE_LIMIT_MAX=100 202 | 203 | # Brute force protection 204 | BRUTE_FORCE_FREE_RETRIES=5 205 | BRUTE_FORCE_MIN_WAIT=300000 206 | BRUTE_FORCE_MAX_WAIT=3600000 207 | BRUTE_FORCE_LIFETIME=86400 208 | 209 | # Database 210 | DB_URI=mongodb://mongo:27017 211 | DB_NAME=mydatabase 212 | MONGO_CLIENT_PORT=9005 213 | 214 | # Cache 215 | REDIS_HOST=redis 216 | REDIS_SERVER_PORT=9079 217 | 218 | # MinIO 219 | MINIO_ENDPOINT=minio 220 | MINIO_ACCESS_KEY=minio-access-key 221 | MINIO_SECRET_KEY=minio-secret-key 222 | MINIO_API_PORT=9500 223 | MINIO_CONSOLE_PORT=9050 224 | 225 | # Maildev 226 | MAILDEV_HOST=maildev 227 | MAILDEV_PORT=1025 228 | MAILDEV_SMTP=9025 229 | MAILDEV_WEBAPP_PORT=9080 230 | ``` 231 | 232 | ## Docker Configuration 233 | 234 | The Docker configuration allows the application to run in isolated containers. The services defined in `docker-compose.yml` include: 235 | 236 | - **app**: The main Node.js application. 237 | - **mongo**: MongoDB database service. 238 | - **redis**: Redis caching service. 239 | - **minio**: MinIO object storage service. 240 | - **maildev**: Maildev service for testing email sending. 241 | 242 | ### Building and Starting Docker Containers 243 | 244 | To build and start the Docker containers, run: 245 | 246 | ```sh 247 | docker-compose up --build 248 | ``` 249 | 250 | This command will build the Docker images and start the services defined in `docker-compose.yml`. 251 | 252 | ## Security Features 253 | 254 | ### Rate Limiting 255 | 256 | The rate limiter middleware is configured to limit the number of requests to the API within a specified time window. This helps protect against DoS attacks. 257 | 258 | ### Brute Force Protection 259 | 260 | Brute force protection is implemented using `express-brute` and `express-brute-mongo`. It limits the number of failed login attempts and progressively increases the wait time between attempts after reaching a threshold. 261 | 262 | ### Hiding Technology Stack 263 | 264 | The `helmet` middleware is used to hide the `X-Powered-By` header to 265 | 266 | obscure the technology stack of the application. 267 | 268 | ### Content Security Policy 269 | 270 | A strict content security policy is enforced using the `helmet` middleware to prevent loading of unauthorized resources. 271 | 272 | ## Linting and Formatting 273 | 274 | This project uses ESLint and Prettier for code linting and formatting. 275 | 276 | ### Running ESLint 277 | 278 | To check for linting errors: 279 | 280 | ```sh 281 | npm run lint 282 | ``` 283 | 284 | To fix linting errors automatically: 285 | 286 | ```sh 287 | npm run lint:fix 288 | ``` 289 | 290 | ### Running Prettier 291 | 292 | To format your code: 293 | 294 | ```sh 295 | npm run format 296 | ``` 297 | 298 | ## Commit Message Guidelines 299 | 300 | To ensure consistent commit messages, this project uses commitlint with husky to enforce commit message guidelines. 301 | 302 | ### Commit Message Format 303 | 304 | - **build**: Changes that affect the build system or external dependencies 305 | - **chore**: Miscellaneous changes that don't affect the main codebase (e.g., configuring development tools, setting up project-specific settings) 306 | - **ci**: Changes to our CI configuration files and scripts 307 | - **docs**: Documentation only changes 308 | - **feat**: A new feature 309 | - **fix**: A bug fix 310 | - **update**: Update something for a specific use case 311 | - **perf**: A code change that improves performance 312 | - **refactor**: A code change that neither fixes a bug nor adds a feature 313 | - **style**: Changes that do not affect the meaning of the code (e.g., white-space, formatting, missing semi-colons) 314 | - **test**: Adding missing tests or correcting existing tests 315 | - **translation**: Changes related to translations or language localization 316 | - **sec**: Changes that address security vulnerabilities, implement security measures, or enhance the overall security of the codebase 317 | 318 | ### Setting Up Commitlint 319 | 320 | Commitlint and Husky are already configured and set up to ensure that commit messages follow the specified format before they are committed to the repository. 321 | 322 | ## Accessing Services 323 | 324 | After running the application, you can access the following services: 325 | 326 | - **Node.js Application**: [http://localhost:9095](http://localhost:9095) 327 | - **MongoDB**: Accessible on port `9005` 328 | - **Redis**: Accessible on port `9079` 329 | - **MinIO API**: Accessible on port `9500` 330 | - **MinIO WebApp**: Accessible on port `9050` 331 | - **MailDev SMTP (external)**: Accessible on port `9025` 332 | - **MailDev WebApp**: Accessible on port `9080` 333 | 334 | ## Contributing 335 | 336 | Contributions, issues, and feature requests are welcome! 337 | 338 | Feel free to check the [issues page](https://github.com/fless-lab/node-ts-starter/issues) if you want to contribute. 339 | 340 | Don't forget to give a star if you find this project useful! 341 | -------------------------------------------------------------------------------- /api.rest: -------------------------------------------------------------------------------- 1 | https://documenter.getpostman.com/view/15120939/2sA3kUGMx7 2 | 3 | 4 | Feel free to let comments is something going wrong -------------------------------------------------------------------------------- /bin/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Path to the project root directory 4 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | 6 | # Path to the .env file 7 | ENV_FILE="$PROJECT_ROOT/.env" 8 | 9 | # Path to the .env.example file 10 | ENV_EXAMPLE_FILE="$PROJECT_ROOT/.env.example" 11 | 12 | echo "🔄 Checking for .env file..." 13 | 14 | # Check if the .env file exists 15 | if [ -f "$ENV_FILE" ]; then 16 | echo "⚠️ .env file already exists. It will be replaced with .env.example." 17 | else 18 | echo "✅ .env file does not exist. It will be created from .env.example." 19 | fi 20 | 21 | # Always replace the .env file with .env.example 22 | if [ -f "$ENV_EXAMPLE_FILE" ]; then 23 | cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" 24 | echo "✅ .env file created/replaced from .env.example." 25 | else 26 | echo "❌ .env.example file not found. Cannot create .env file." 27 | exit 1 28 | fi 29 | 30 | echo "🔄 Installing npm dependencies..." 31 | npm install 32 | echo "✅ npm dependencies installed." 33 | -------------------------------------------------------------------------------- /bin/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Path to the project root directory 4 | PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | 6 | # Default environment is development 7 | ENVIRONMENT="development" 8 | 9 | # Check for --prod argument 10 | if [ "$1" == "--prod" ]; then 11 | ENVIRONMENT="production" 12 | fi 13 | 14 | echo "🔄 Checking for Docker installation..." 15 | 16 | # Check if Docker is installed 17 | if ! command -v docker &> /dev/null; then 18 | echo "❌ Docker could not be found. Please install Docker and try again." 19 | exit 1 20 | fi 21 | 22 | echo "✅ Docker is installed." 23 | 24 | echo "🔄 Checking for Docker Compose installation..." 25 | 26 | # Check if Docker Compose is installed and determine which command to use 27 | if command -v docker-compose &> /dev/null; then 28 | DOCKER_COMPOSE_CMD="docker-compose" 29 | echo "✅ Docker Compose (standalone) is installed." 30 | elif docker compose version &> /dev/null; then 31 | DOCKER_COMPOSE_CMD="docker compose" 32 | echo "✅ Docker Compose (plugin) is installed." 33 | else 34 | echo "❌ Docker Compose could not be found. Please install Docker Compose and try again." 35 | exit 1 36 | fi 37 | 38 | # Run the install script 39 | echo "🔄 Running install.sh..." 40 | bash "$PROJECT_ROOT/bin/install.sh" 41 | 42 | # Inject NODE_ENV into .env file 43 | echo "NODE_ENV=$ENVIRONMENT" >> "$PROJECT_ROOT/.env" 44 | echo "" 45 | echo "🔄 NODE_ENV set to $ENVIRONMENT in .env file." 46 | 47 | # Start the Docker containers 48 | echo "🔄 Starting Docker containers in $ENVIRONMENT mode..." 49 | $DOCKER_COMPOSE_CMD up --build 50 | echo "✅ Docker containers started." -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | //----------------------------------------------------------------------------------------------------------------------------------------------------- 2 | // build: Changes that affect the build system or external dependencies 3 | // chore: Used for miscellaneous changes that don't affect the main codebase (e.g., configuring development tools, setting up project-specific settings) 4 | // ci: Changes to our CI configuration files and scripts 5 | // docs: Documentation only changes 6 | // feat: A new feature 7 | // fix: A bug fix 8 | // update: Update something for a specific use case 9 | // perf: A code change that improves performance 10 | // refactor: A code change that neither fixes a bug nor adds a feature 11 | // style: Changes that do not affect the meaning of the code (e.g., white-space, formatting, missing semi-colons) 12 | // test: Adding missing tests or correcting existing tests 13 | // translation: Changes related to translations or language localization 14 | // sec: Changes that address security vulnerabilities, implement security measures, or enhance the overall security of the codebase 15 | // ----------------------------------------------------------------------------------------------------------------------------------------------------- 16 | 17 | module.exports = { 18 | parserPreset: { 19 | parserOpts: { 20 | headerPattern: /^(\w+)(?:\((\w+)\))?:\s(.*)$/, 21 | headerCorrespondence: ['type', 'scope', 'subject'], 22 | }, 23 | }, 24 | plugins: [ 25 | { 26 | rules: { 27 | 'header-match-team-pattern': (parsed) => { 28 | const { type, subject } = parsed; 29 | const allowedTypes = [ 30 | 'build', 31 | 'chore', 32 | 'ci', 33 | 'docs', 34 | 'feat', 35 | 'update', 36 | 'fix', 37 | 'perf', 38 | 'refactor', 39 | 'style', 40 | 'test', 41 | 'translation', 42 | 'sec', 43 | ]; 44 | 45 | if (!type || !subject) { 46 | return [ 47 | false, 48 | "\x1b[31mERROR\x1b[0m: Please follow the format 'feat(auth): user login form' or 'fix: fixing data problems'", 49 | ]; 50 | } 51 | 52 | if (!allowedTypes.includes(type)) { 53 | return [ 54 | false, 55 | `\x1b[31mERROR\x1b[0m: The commit type '${type}' is not allowed. Allowed types are: [${allowedTypes.join(', ')}]`, 56 | ]; 57 | } 58 | 59 | return [true, '']; 60 | }, 61 | }, 62 | }, 63 | ], 64 | rules: { 65 | 'header-match-team-pattern': [2, 'always'], 66 | 'subject-empty': [2, 'never'], 67 | 'body-leading-blank': [2, 'always'], 68 | 'footer-leading-blank': [2, 'always'], 69 | // 'footer-empty': [2, 'always'], 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: . 6 | container_name: node-ts-starter-app 7 | ports: 8 | - "${PORT}:${PORT}" 9 | env_file: 10 | - .env 11 | depends_on: 12 | - mongo 13 | - redis 14 | - minio 15 | - maildev 16 | volumes: 17 | - .:/usr/src/app 18 | 19 | mongo: 20 | image: mongo 21 | container_name: node-ts-starter-mongo 22 | ports: 23 | - "${MONGO_CLIENT_PORT}:27017" 24 | volumes: 25 | - mongo-data:/data/db 26 | 27 | redis: 28 | image: redis:latest 29 | container_name: node-ts-starter-redis 30 | ports: 31 | - "${REDIS_SERVER_PORT}:6379" 32 | 33 | minio: 34 | image: minio/minio 35 | container_name: node-ts-starter-minio 36 | command: server /data --console-address ":9001" 37 | ports: 38 | - "${MINIO_API_PORT}:9000" 39 | - "${MINIO_CONSOLE_PORT}:9001" 40 | environment: 41 | MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} 42 | MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY} 43 | volumes: 44 | - minio-data:/data 45 | 46 | maildev: 47 | image: maildev/maildev 48 | container_name: node-ts-starter-maildev 49 | ports: 50 | - "${MAILDEV_SMTP}:1025" 51 | - "${MAILDEV_WEBAPP_PORT}:1080" 52 | healthcheck: 53 | test: ["CMD", "wget", "--spider", "http://localhost:1080"] 54 | interval: 30s 55 | timeout: 10s 56 | retries: 3 57 | 58 | volumes: 59 | mongo-data: 60 | minio-data: 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-ts-starter", 3 | "version": "1.0.0", 4 | "description": "A simple and lightweight nodejs boilerplate using typescript", 5 | "main": "build/server.js", 6 | "scripts": { 7 | "build": "npx tsc -p .", 8 | "start:prod": "node .", 9 | "start": "npx nodemon src/server.ts", 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "lint": "eslint 'src/**/*.{js,ts}'", 12 | "lint:fix": "eslint 'src/**/*.{js,ts}' --fix", 13 | "format": "prettier --write 'src/**/*.{js,ts,json,css,md,html,pug}'", 14 | "prepare": "husky install" 15 | }, 16 | "lint-staged": { 17 | "src/**/*.{js,ts,tsx}": [ 18 | "eslint --fix", 19 | "prettier --write" 20 | ], 21 | "src/**/*.{json,css,md,html,pug}": [ 22 | "prettier --write" 23 | ] 24 | }, 25 | "keywords": [ 26 | "node.js", 27 | "express", 28 | "api", 29 | "typescript", 30 | "docker" 31 | ], 32 | "author": "Abdou-Raouf ATARMLA", 33 | "license": "ISC", 34 | "dependencies": { 35 | "@types/connect-flash": "^0.0.40", 36 | "@types/express-session": "^1.18.0", 37 | "bcrypt": "^5.1.1", 38 | "connect-flash": "^0.1.1", 39 | "cors": "^2.8.5", 40 | "dotenv": "^16.4.5", 41 | "ejs": "^3.1.10", 42 | "express": "^4.19.2", 43 | "express-brute": "^1.0.1", 44 | "express-brute-redis": "^0.0.1", 45 | "express-list-endpoints": "^7.1.0", 46 | "express-rate-limit": "^7.3.1", 47 | "express-session": "^1.18.0", 48 | "handlebars": "^4.7.8", 49 | "helmet": "^7.1.0", 50 | "ioredis": "^5.4.1", 51 | "joi": "^17.13.3", 52 | "jsonwebtoken": "^9.0.2", 53 | "minio": "^8.0.0", 54 | "mongoose": "^8.4.3", 55 | "morgan": "^1.10.0", 56 | "nodemailer": "^6.9.14", 57 | "rate-limiter-flexible": "^5.0.3", 58 | "winston": "^3.13.1" 59 | }, 60 | "devDependencies": { 61 | "@commitlint/cli": "^19.3.0", 62 | "@commitlint/config-conventional": "^19.2.2", 63 | "@types/bcrypt": "^5.0.2", 64 | "@types/cors": "^2.8.17", 65 | "@types/express": "^4.17.21", 66 | "@types/express-brute": "^1.0.5", 67 | "@types/express-brute-mongo": "^0.0.39", 68 | "@types/jsonwebtoken": "^9.0.6", 69 | "@types/morgan": "^1.9.9", 70 | "@types/nodemailer": "^6.4.15", 71 | "@typescript-eslint/eslint-plugin": "^5.57.1", 72 | "@typescript-eslint/parser": "^5.57.1", 73 | "eslint": "^8.56.0", 74 | "eslint-config-prettier": "^9.1.0", 75 | "eslint-plugin-import": "^2.29.1", 76 | "eslint-plugin-node": "^11.1.0", 77 | "eslint-plugin-prettier": "^5.1.3", 78 | "eslint-plugin-promise": "^6.2.0", 79 | "husky": "^9.0.11", 80 | "lint-staged": "^15.2.7", 81 | "nodemon": "^3.1.3", 82 | "postcss": "^8.4.38", 83 | "prettier": "^3.3.2", 84 | "prettier-plugin-pug": "^1.0.0-alpha.8", 85 | "tailwindcss": "^3.4.4", 86 | "ts-node": "^10.9.2", 87 | "typescript": "^5.0.4" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /project-tree.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def tree(dir_path, indent='', ignore_list=None): 4 | # Default ignore list includes common boilerplate directories 5 | if ignore_list is None: 6 | ignore_list = {'node_modules', '.git', '__pycache__', '.DS_Store', '.husky'} 7 | 8 | # Get the list of all files and directories in the given directory 9 | items = [item for item in os.listdir(dir_path) if item not in ignore_list] 10 | 11 | # Iterate over each item 12 | for index, item in enumerate(sorted(items)): 13 | # Check if it's the last item to use a different character 14 | if index == len(items) - 1: 15 | print(indent + '└── ' + item) 16 | new_indent = indent + ' ' 17 | else: 18 | print(indent + '├── ' + item) 19 | new_indent = indent + '│ ' 20 | 21 | # Get the full path of the item 22 | item_path = os.path.join(dir_path, item) 23 | 24 | # If it's a directory, recursively call the tree function 25 | if os.path.isdir(item_path): 26 | tree(item_path, new_indent, ignore_list) 27 | 28 | # Call the tree function with the current directory 29 | if __name__ == '__main__': 30 | project_root = os.path.dirname(os.path.abspath(__file__)) 31 | print(project_root) 32 | tree(project_root) 33 | 34 | 35 | 36 | """Copyright 37 | 38 | This code is fully generated by chatgpt.com 39 | """ -------------------------------------------------------------------------------- /public/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fless-lab/node-ts-starter/77cac390035438dd1d9e1545cab534134db2cd4a/public/empty.txt -------------------------------------------------------------------------------- /src/apps/auth/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { AuthService } from '../services'; 4 | import { ApiResponse, ErrorResponseType } from '../../../common/shared'; 5 | 6 | class AuthController { 7 | static async register( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | ): Promise { 12 | try { 13 | const response = await AuthService.register(req.body); 14 | if (response.success) { 15 | ApiResponse.success(res, response, 201); 16 | } else { 17 | throw response; 18 | } 19 | } catch (error) { 20 | ApiResponse.error(res, error as ErrorResponseType); 21 | } 22 | } 23 | 24 | static async verifyAccount( 25 | req: Request, 26 | res: Response, 27 | next: NextFunction, 28 | ): Promise { 29 | try { 30 | const response = await AuthService.verifyAccount(req.body); 31 | if (response.success) { 32 | ApiResponse.success(res, response); 33 | } else { 34 | throw response; 35 | } 36 | } catch (error) { 37 | ApiResponse.error(res, error as ErrorResponseType); 38 | } 39 | } 40 | 41 | static async loginWithPassword( 42 | req: Request, 43 | res: Response, 44 | next: NextFunction, 45 | ): Promise { 46 | try { 47 | const response = await AuthService.loginWithPassword(req.body); 48 | if (response.success) { 49 | ApiResponse.success(res, response); 50 | } else { 51 | throw response; 52 | } 53 | } catch (error) { 54 | ApiResponse.error(res, error as ErrorResponseType); 55 | } 56 | } 57 | 58 | static async generateLoginOtp( 59 | req: Request, 60 | res: Response, 61 | next: NextFunction, 62 | ): Promise { 63 | try { 64 | const response = await AuthService.generateLoginOtp(req.body.email); 65 | if (response.success) { 66 | ApiResponse.success(res, response); 67 | } else { 68 | throw response; 69 | } 70 | } catch (error) { 71 | ApiResponse.error(res, error as ErrorResponseType); 72 | } 73 | } 74 | 75 | static async loginWithOtp( 76 | req: Request, 77 | res: Response, 78 | next: NextFunction, 79 | ): Promise { 80 | try { 81 | const response = await AuthService.loginWithOtp(req.body); 82 | if (response.success) { 83 | ApiResponse.success(res, response); 84 | } else { 85 | throw response; 86 | } 87 | } catch (error) { 88 | ApiResponse.error(res, error as ErrorResponseType); 89 | } 90 | } 91 | 92 | static async refreshToken( 93 | req: Request, 94 | res: Response, 95 | next: NextFunction, 96 | ): Promise { 97 | try { 98 | const response = await AuthService.refresh(req.body.refreshToken); 99 | if (response.success) { 100 | ApiResponse.success(res, response); 101 | } else { 102 | throw response; 103 | } 104 | } catch (error) { 105 | ApiResponse.error(res, error as ErrorResponseType); 106 | } 107 | } 108 | 109 | static async logout( 110 | req: Request, 111 | res: Response, 112 | next: NextFunction, 113 | ): Promise { 114 | try { 115 | const { accessToken, refreshToken } = req.body; 116 | const response = await AuthService.logout(accessToken, refreshToken); 117 | if (response.success) { 118 | ApiResponse.success(res, response, 202); 119 | } else { 120 | throw response; 121 | } 122 | } catch (error) { 123 | ApiResponse.error(res, error as ErrorResponseType); 124 | } 125 | } 126 | 127 | static async forgotPassword( 128 | req: Request, 129 | res: Response, 130 | next: NextFunction, 131 | ): Promise { 132 | try { 133 | const response = await AuthService.forgotPassword(req.body.email); 134 | if (response.success) { 135 | ApiResponse.success(res, response); 136 | } else { 137 | throw response; 138 | } 139 | } catch (error) { 140 | ApiResponse.error(res, error as ErrorResponseType); 141 | } 142 | } 143 | 144 | static async resetPassword( 145 | req: Request, 146 | res: Response, 147 | next: NextFunction, 148 | ): Promise { 149 | try { 150 | const response = await AuthService.resetPassword(req.body); 151 | if (response.success) { 152 | ApiResponse.success(res, response); 153 | } else { 154 | throw response; 155 | } 156 | } catch (error) { 157 | ApiResponse.error(res, error as ErrorResponseType); 158 | } 159 | } 160 | } 161 | 162 | export default AuthController; 163 | -------------------------------------------------------------------------------- /src/apps/auth/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthController } from './auth.controller'; 2 | export { default as OTPController } from './otp.controller'; 3 | -------------------------------------------------------------------------------- /src/apps/auth/controllers/otp.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { OTPService } from '../services'; 4 | import { ApiResponse, ErrorResponseType } from '../../../common/shared'; 5 | 6 | class OTPController { 7 | static async generateOTP( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | ): Promise { 12 | try { 13 | const { email, purpose } = req.body; 14 | const response = await OTPService.generate(email, purpose); 15 | if (response.success) { 16 | ApiResponse.success(res, response, 201); 17 | } else { 18 | throw response; 19 | } 20 | } catch (error) { 21 | ApiResponse.error(res, error as ErrorResponseType); 22 | } 23 | } 24 | 25 | static async validateOTP( 26 | req: Request, 27 | res: Response, 28 | next: NextFunction, 29 | ): Promise { 30 | try { 31 | const { email, code, purpose } = req.body; 32 | const response = await OTPService.validate(email, code, purpose); 33 | if (response.success) { 34 | ApiResponse.success(res, response); 35 | } else { 36 | throw response; 37 | } 38 | } catch (error) { 39 | ApiResponse.error(res, error as ErrorResponseType); 40 | } 41 | } 42 | } 43 | 44 | export default OTPController; 45 | -------------------------------------------------------------------------------- /src/apps/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | export * from './models'; 3 | export * from './repositories'; 4 | export * from './routes'; 5 | export * from './services'; 6 | export * from './types'; 7 | export * from './validators'; 8 | -------------------------------------------------------------------------------- /src/apps/auth/models/_plugins/attemp-limiting.plugin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ceci c'est juste un exemple de plugin 3 | */ 4 | 5 | import { Schema } from 'mongoose'; 6 | 7 | export const attemptLimitingPlugin = ( 8 | schema: Schema, 9 | options?: { maxAttempts?: number }, 10 | ) => { 11 | schema.add({ attempts: { type: Number, default: 0 } }); 12 | 13 | schema.methods.incrementAttempts = async function () { 14 | this.attempts += 1; 15 | const maxAttempts = options?.maxAttempts || 3; 16 | 17 | if (this.attempts >= maxAttempts) { 18 | this.used = true; 19 | this.isFresh = false; 20 | } 21 | await this.save(); 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/apps/auth/models/_plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './attemp-limiting.plugin'; 2 | -------------------------------------------------------------------------------- /src/apps/auth/models/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OTPModel } from './otp.model'; 2 | -------------------------------------------------------------------------------- /src/apps/auth/models/otp.model.ts: -------------------------------------------------------------------------------- 1 | import { IOTPModel } from '../types'; 2 | import { config } from '../../../core/config'; 3 | import { Schema } from 'mongoose'; 4 | import { BaseModel, createBaseSchema } from '../../../core/engine'; 5 | import { attemptLimitingPlugin } from './_plugins'; 6 | 7 | const OTP_MODEL_NAME = 'OTP'; 8 | 9 | const otpSchema = createBaseSchema( 10 | { 11 | code: { 12 | type: String, 13 | required: true, 14 | }, 15 | user: { 16 | type: Schema.Types.ObjectId, 17 | ref: 'User', 18 | required: true, 19 | }, 20 | used: { 21 | type: Boolean, 22 | default: false, 23 | }, 24 | isFresh: { 25 | type: Boolean, 26 | default: true, 27 | }, 28 | expiresAt: { 29 | type: Date, 30 | required: true, 31 | }, 32 | purpose: { 33 | type: String, 34 | enum: Object.keys(config.otp.purposes), 35 | required: true, 36 | }, 37 | attempts: { 38 | type: Number, 39 | default: 0, 40 | }, 41 | }, 42 | { 43 | excludePlugins: ['softDelete'], 44 | includePlugins: [[attemptLimitingPlugin, { maxAttempts: 5 }]], 45 | modelName: OTP_MODEL_NAME, 46 | }, 47 | ); 48 | 49 | const OTPModel = new BaseModel(OTP_MODEL_NAME, otpSchema).getModel(); 50 | 51 | export default OTPModel; 52 | -------------------------------------------------------------------------------- /src/apps/auth/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OTPRepository } from './otp.repo'; 2 | -------------------------------------------------------------------------------- /src/apps/auth/repositories/otp.repo.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'mongoose'; 2 | import { config } from '../../../core/config'; 3 | import { IOTPModel, TOTPPurpose } from '../types'; 4 | import { generateRandomOTP } from '../../../helpers'; 5 | import { BaseRepository } from '../../../core/engine'; 6 | 7 | class OTPRepository extends BaseRepository { 8 | constructor(model: Model) { 9 | super(model); 10 | } 11 | 12 | async generateCode(user: string, purpose: TOTPPurpose): Promise { 13 | await this.invalidateOldCodes(user, purpose); 14 | const otp = new this.model({ 15 | code: generateRandomOTP(config.otp.length), 16 | expiresAt: new Date(Date.now() + config.otp.expiration), 17 | user, 18 | purpose, 19 | }); 20 | return await otp.save(); 21 | } 22 | 23 | async markAsUsed(otpId: string): Promise { 24 | return await this.model 25 | .findByIdAndUpdate(otpId, { used: true }, { new: true }) 26 | .exec(); 27 | } 28 | 29 | async isExpired(otp: IOTPModel): Promise { 30 | return otp.expiresAt ? Date.now() > otp.expiresAt.getTime() : true; 31 | } 32 | 33 | async isValid(code: string): Promise { 34 | const otp = await this.findOne({ code, isFresh: true, used: false }); 35 | return otp ? Date.now() <= otp.expiresAt.getTime() : false; 36 | } 37 | 38 | async findValidCodeByUser( 39 | code: string, 40 | user: string, 41 | purpose: TOTPPurpose, 42 | ): Promise { 43 | return await this.findOne({ 44 | code, 45 | user, 46 | isFresh: true, 47 | used: false, 48 | purpose, 49 | }); 50 | } 51 | 52 | async invalidateOldCodes(user: string, purpose: TOTPPurpose): Promise { 53 | await this.model 54 | .updateMany({ user, used: false, purpose }, { $set: { isFresh: false } }) 55 | .exec(); 56 | } 57 | } 58 | 59 | export default OTPRepository; 60 | -------------------------------------------------------------------------------- /src/apps/auth/routes/auth.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { bruteForceMiddleware, validate } from '../../../common/shared'; 4 | import { AuthController } from '../controllers'; 5 | import { 6 | forgotPasswordSchema, 7 | generateLoginOtpSchema, 8 | loginWithOtpSchema, 9 | loginWithPasswordSchema, 10 | logoutSchema, 11 | refreshSchema, 12 | registerSchema, 13 | resetPasswordSchema, 14 | verifyAccountSchema, 15 | } from '../validators'; 16 | 17 | const router = Router(); 18 | 19 | router.post('/register', validate(registerSchema), AuthController.register); 20 | router.post( 21 | '/verify-account', 22 | validate(verifyAccountSchema), 23 | AuthController.verifyAccount, 24 | ); 25 | router.post( 26 | '/generate-login-otp', 27 | validate(generateLoginOtpSchema), 28 | AuthController.generateLoginOtp, 29 | ); 30 | router.post( 31 | '/login-with-password', 32 | validate(loginWithPasswordSchema), 33 | bruteForceMiddleware, 34 | AuthController.loginWithPassword, 35 | ); 36 | router.post( 37 | '/login-with-otp', 38 | validate(loginWithOtpSchema), 39 | bruteForceMiddleware, 40 | AuthController.loginWithOtp, 41 | ); 42 | router.post( 43 | '/forgot-password', 44 | validate(forgotPasswordSchema), 45 | AuthController.forgotPassword, 46 | ); 47 | router.patch( 48 | '/reset-password', 49 | validate(resetPasswordSchema), 50 | AuthController.resetPassword, 51 | ); 52 | router.post('/refresh', validate(refreshSchema), AuthController.refreshToken); 53 | router.post('/logout', validate(logoutSchema), AuthController.logout); 54 | 55 | export default router; 56 | -------------------------------------------------------------------------------- /src/apps/auth/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthRoutes } from './auth.routes'; 2 | export { default as OTPRoutes } from './otp.routes'; 3 | -------------------------------------------------------------------------------- /src/apps/auth/routes/otp.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { OTPController } from '../controllers'; 3 | 4 | const router = Router(); 5 | 6 | router.post('/generate', OTPController.generateOTP); 7 | router.post('/validate', OTPController.validateOTP); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /src/apps/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { OTPService } from '.'; 2 | import { 3 | ErrorResponse, 4 | ErrorResponseType, 5 | JwtService, 6 | MailServiceUtilities, 7 | SuccessResponseType, 8 | } from '../../../common/shared'; 9 | import { config } from '../../../core/config'; 10 | import { IUserModel, UserService } from '../../users'; 11 | import { IOTPModel } from '../types'; 12 | 13 | class AuthService { 14 | async register( 15 | payload: any, 16 | ): Promise | ErrorResponseType> { 17 | try { 18 | const { email } = payload; 19 | const userResponse = (await UserService.findOne({ 20 | email, 21 | })) as SuccessResponseType; 22 | 23 | if (userResponse.success && userResponse.document) { 24 | throw new ErrorResponse( 25 | 'UNIQUE_FIELD_ERROR', 26 | 'The entered email is already registered.', 27 | ); 28 | } 29 | 30 | const createUserResponse = (await UserService.create( 31 | payload, 32 | )) as SuccessResponseType; 33 | 34 | if (!createUserResponse.success || !createUserResponse.document) { 35 | throw createUserResponse.error; 36 | } 37 | 38 | await MailServiceUtilities.sendAccountCreationEmail({ 39 | to: email, 40 | firstname: createUserResponse.document.firstname, 41 | }); 42 | 43 | const otpResponse = (await OTPService.generate( 44 | email, 45 | config.otp.purposes.ACCOUNT_VERIFICATION.code, 46 | )) as SuccessResponseType; 47 | 48 | if (!otpResponse.success || !otpResponse.document) { 49 | throw otpResponse.error; 50 | } 51 | 52 | return { 53 | success: true, 54 | document: { 55 | user: createUserResponse.document, 56 | otp: otpResponse.document, 57 | }, 58 | }; 59 | } catch (error) { 60 | return { 61 | success: false, 62 | error: 63 | error instanceof ErrorResponse 64 | ? error 65 | : new ErrorResponse( 66 | 'INTERNAL_SERVER_ERROR', 67 | (error as Error).message, 68 | ), 69 | }; 70 | } 71 | } 72 | 73 | async verifyAccount( 74 | payload: any, 75 | ): Promise | ErrorResponseType> { 76 | try { 77 | const { email, code } = payload; 78 | const userResponse = (await UserService.findOne({ 79 | email, 80 | })) as SuccessResponseType; 81 | 82 | if (!userResponse.success || !userResponse.document) { 83 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 84 | } 85 | 86 | if (userResponse.document.verified) { 87 | return { success: true }; // If already verified, return success without further actions 88 | } 89 | 90 | const validateOtpResponse = await OTPService.validate( 91 | email, 92 | code, 93 | config.otp.purposes.ACCOUNT_VERIFICATION.code, 94 | ); 95 | 96 | if (!validateOtpResponse.success) { 97 | throw validateOtpResponse.error; 98 | } 99 | 100 | const verifyUserResponse = await UserService.markAsVerified(email); 101 | 102 | if (!verifyUserResponse.success) { 103 | throw verifyUserResponse.error; 104 | } 105 | 106 | return { success: true }; 107 | } catch (error) { 108 | return { 109 | success: false, 110 | error: 111 | error instanceof ErrorResponse 112 | ? error 113 | : new ErrorResponse( 114 | 'INTERNAL_SERVER_ERROR', 115 | (error as Error).message, 116 | ), 117 | }; 118 | } 119 | } 120 | 121 | async generateLoginOtp( 122 | email: string, 123 | ): Promise | ErrorResponseType> { 124 | try { 125 | const userResponse = (await UserService.findOne({ 126 | email, 127 | })) as SuccessResponseType; 128 | 129 | if (!userResponse.success || !userResponse.document) { 130 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 131 | } 132 | 133 | const user = userResponse.document; 134 | 135 | if (!user.verified) { 136 | throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); 137 | } 138 | 139 | if (!user.active) { 140 | throw new ErrorResponse( 141 | 'FORBIDDEN', 142 | 'Inactive account, please contact admins.', 143 | ); 144 | } 145 | 146 | const otpResponse = await OTPService.generate( 147 | email, 148 | config.otp.purposes.LOGIN_CONFIRMATION.code, 149 | ); 150 | 151 | if (!otpResponse.success) { 152 | throw otpResponse.error; 153 | } 154 | 155 | return otpResponse; 156 | } catch (error) { 157 | return { 158 | success: false, 159 | error: 160 | error instanceof ErrorResponse 161 | ? error 162 | : new ErrorResponse( 163 | 'INTERNAL_SERVER_ERROR', 164 | (error as Error).message, 165 | ), 166 | }; 167 | } 168 | } 169 | 170 | async loginWithPassword( 171 | payload: any, 172 | ): Promise | ErrorResponseType> { 173 | try { 174 | const { email, password } = payload; 175 | const userResponse = (await UserService.findOne({ 176 | email, 177 | })) as SuccessResponseType; 178 | 179 | if (!userResponse.success || !userResponse.document) { 180 | throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); 181 | } 182 | 183 | const user = userResponse.document; 184 | const isValidPasswordResponse = (await UserService.isValidPassword( 185 | user.id, 186 | password, 187 | )) as SuccessResponseType<{ isValid: boolean }>; 188 | 189 | if ( 190 | !isValidPasswordResponse.success || 191 | !isValidPasswordResponse.document?.isValid 192 | ) { 193 | throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); 194 | } 195 | 196 | if (!user.verified) { 197 | throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); 198 | } 199 | 200 | if (!user.active) { 201 | throw new ErrorResponse( 202 | 'FORBIDDEN', 203 | 'Inactive account, please contact admins.', 204 | ); 205 | } 206 | 207 | const accessToken = await JwtService.signAccessToken(user.id); 208 | const refreshToken = await JwtService.signRefreshToken(user.id); 209 | 210 | return { 211 | success: true, 212 | document: { 213 | token: { access: accessToken, refresh: refreshToken }, 214 | user, 215 | }, 216 | }; 217 | } catch (error) { 218 | return { 219 | success: false, 220 | error: 221 | error instanceof ErrorResponse 222 | ? error 223 | : new ErrorResponse( 224 | 'INTERNAL_SERVER_ERROR', 225 | (error as Error).message, 226 | ), 227 | }; 228 | } 229 | } 230 | 231 | async loginWithOtp( 232 | payload: any, 233 | ): Promise | ErrorResponseType> { 234 | try { 235 | const { email, code } = payload; 236 | const userResponse = (await UserService.findOne({ 237 | email, 238 | })) as SuccessResponseType; 239 | 240 | if (!userResponse.success || !userResponse.document) { 241 | throw new ErrorResponse('UNAUTHORIZED', 'Invalid credentials.'); 242 | } 243 | 244 | const user = userResponse.document; 245 | 246 | const validateOtpResponse = await OTPService.validate( 247 | email, 248 | code, 249 | config.otp.purposes.LOGIN_CONFIRMATION.code, 250 | ); 251 | 252 | if (!validateOtpResponse.success) { 253 | throw validateOtpResponse.error; 254 | } 255 | 256 | if (!user.verified) { 257 | throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); 258 | } 259 | 260 | if (!user.active) { 261 | throw new ErrorResponse( 262 | 'FORBIDDEN', 263 | 'Inactive account, please contact admins.', 264 | ); 265 | } 266 | 267 | const accessToken = await JwtService.signAccessToken(user.id); 268 | const refreshToken = await JwtService.signRefreshToken(user.id); 269 | 270 | return { 271 | success: true, 272 | document: { 273 | token: { access: accessToken, refresh: refreshToken }, 274 | user, 275 | }, 276 | }; 277 | } catch (error) { 278 | return { 279 | success: false, 280 | error: 281 | error instanceof ErrorResponse 282 | ? error 283 | : new ErrorResponse( 284 | 'INTERNAL_SERVER_ERROR', 285 | (error as Error).message, 286 | ), 287 | }; 288 | } 289 | } 290 | 291 | async refresh( 292 | refreshToken: string, 293 | ): Promise | ErrorResponseType> { 294 | try { 295 | if (!refreshToken) { 296 | throw new ErrorResponse('BAD_REQUEST', 'Refresh token is required.'); 297 | } 298 | 299 | const userId = await JwtService.verifyRefreshToken(refreshToken); 300 | const accessToken = await JwtService.signAccessToken(userId); 301 | // Refresh token change to ensure rotation 302 | const newRefreshToken = await JwtService.signRefreshToken(userId); 303 | 304 | return { 305 | success: true, 306 | document: { token: { access: accessToken, refresh: newRefreshToken } }, 307 | }; 308 | } catch (error) { 309 | return { 310 | success: false, 311 | error: 312 | error instanceof ErrorResponse 313 | ? error 314 | : new ErrorResponse( 315 | 'INTERNAL_SERVER_ERROR', 316 | (error as Error).message, 317 | ), 318 | }; 319 | } 320 | } 321 | 322 | async logout( 323 | accessToken: string, 324 | refreshToken: string, 325 | ): Promise | ErrorResponseType> { 326 | try { 327 | if (!refreshToken || !accessToken) { 328 | throw new ErrorResponse( 329 | 'BAD_REQUEST', 330 | 'Refresh and access token are required.', 331 | ); 332 | } 333 | 334 | const { userId: userIdFromRefresh } = 335 | await JwtService.checkRefreshToken(refreshToken); 336 | const { userId: userIdFromAccess } = 337 | await JwtService.checkAccessToken(accessToken); 338 | 339 | if (userIdFromRefresh !== userIdFromAccess) { 340 | throw new ErrorResponse( 341 | 'UNAUTHORIZED', 342 | 'Access token does not match refresh token.', 343 | ); 344 | } 345 | 346 | // Blacklist the access token 347 | await JwtService.blacklistToken(accessToken); 348 | 349 | // Remove the refresh token from Redis 350 | await JwtService.removeFromRedis(userIdFromRefresh); 351 | 352 | return { success: true }; 353 | } catch (error) { 354 | return { 355 | success: false, 356 | error: 357 | error instanceof ErrorResponse 358 | ? error 359 | : new ErrorResponse( 360 | 'INTERNAL_SERVER_ERROR', 361 | (error as Error).message, 362 | ), 363 | }; 364 | } 365 | } 366 | 367 | async forgotPassword( 368 | email: string, 369 | ): Promise | ErrorResponseType> { 370 | try { 371 | if (!email) { 372 | throw new ErrorResponse('BAD_REQUEST', 'Email should be provided.'); 373 | } 374 | 375 | const userResponse = (await UserService.findOne({ 376 | email, 377 | })) as SuccessResponseType; 378 | 379 | if (!userResponse.success || !userResponse.document) { 380 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 381 | } 382 | 383 | const user = userResponse.document; 384 | 385 | if (!user.verified) { 386 | throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); 387 | } 388 | 389 | if (!user.active) { 390 | throw new ErrorResponse( 391 | 'FORBIDDEN', 392 | 'Inactive account, please contact admins.', 393 | ); 394 | } 395 | 396 | const otpResponse = await OTPService.generate( 397 | email, 398 | config.otp.purposes.FORGOT_PASSWORD.code, 399 | ); 400 | 401 | if (!otpResponse.success) { 402 | throw otpResponse.error; 403 | } 404 | 405 | return { success: true }; 406 | } catch (error) { 407 | return { 408 | success: false, 409 | error: 410 | error instanceof ErrorResponse 411 | ? error 412 | : new ErrorResponse( 413 | 'INTERNAL_SERVER_ERROR', 414 | (error as Error).message, 415 | ), 416 | }; 417 | } 418 | } 419 | 420 | async resetPassword( 421 | payload: any, 422 | ): Promise | ErrorResponseType> { 423 | try { 424 | // We suppose a verification about new password and confirmation password have already been done 425 | const { email, code, newPassword } = payload; 426 | 427 | const userResponse = (await UserService.findOne({ 428 | email, 429 | })) as SuccessResponseType; 430 | 431 | if (!userResponse.success || !userResponse.document) { 432 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 433 | } 434 | 435 | const user = userResponse.document; 436 | 437 | if (!user.verified) { 438 | throw new ErrorResponse('UNAUTHORIZED', 'Unverified account.'); 439 | } 440 | 441 | if (!user.active) { 442 | throw new ErrorResponse( 443 | 'FORBIDDEN', 444 | 'Inactive account, please contact admins.', 445 | ); 446 | } 447 | 448 | const validateOtpResponse = await OTPService.validate( 449 | email, 450 | code, 451 | config.otp.purposes.FORGOT_PASSWORD.code, 452 | ); 453 | 454 | if (!validateOtpResponse.success) { 455 | throw validateOtpResponse.error; 456 | } 457 | 458 | const updatePasswordResponse = await UserService.updatePassword( 459 | user.id, 460 | newPassword, 461 | ); 462 | 463 | if (!updatePasswordResponse.success) { 464 | throw updatePasswordResponse.error; 465 | } 466 | 467 | return { success: true }; 468 | } catch (error) { 469 | return { 470 | success: false, 471 | error: 472 | error instanceof ErrorResponse 473 | ? error 474 | : new ErrorResponse( 475 | 'INTERNAL_SERVER_ERROR', 476 | (error as Error).message, 477 | ), 478 | }; 479 | } 480 | } 481 | } 482 | 483 | export default new AuthService(); 484 | -------------------------------------------------------------------------------- /src/apps/auth/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AuthService } from './auth.service'; 2 | export { default as OTPService } from './otp.service'; 3 | -------------------------------------------------------------------------------- /src/apps/auth/services/otp.service.ts: -------------------------------------------------------------------------------- 1 | import { generateRandomOTP } from '../../../helpers'; 2 | import { 3 | ErrorResponse, 4 | ErrorResponseType, 5 | MailServiceUtilities, 6 | SuccessResponseType, 7 | } from '../../../common/shared'; 8 | import { IUserModel, UserService } from '../../users'; 9 | import { OTPModel } from '../models'; 10 | import { IOTPModel, TOTPPurpose } from '../types'; 11 | import { config } from '../../../core/config'; 12 | import { BaseService } from '../../../core/engine'; 13 | import { OTPRepository } from '../repositories'; 14 | 15 | class OTPService extends BaseService { 16 | constructor() { 17 | const otpRepo = new OTPRepository(OTPModel); 18 | super(otpRepo, false); 19 | } 20 | 21 | async generate( 22 | email: string, 23 | purpose: TOTPPurpose, 24 | ): Promise | ErrorResponseType> { 25 | try { 26 | const userResponse = (await UserService.findOne({ 27 | email, 28 | })) as SuccessResponseType; 29 | if (!userResponse.success || !userResponse.document) { 30 | // TODO: Customize this kind of error to override BaseService generic not found 31 | throw userResponse.error; 32 | } 33 | 34 | const user = userResponse.document; 35 | await this.repository.invalidateOldCodes(user.id, purpose); 36 | 37 | const otp = await this.repository.create({ 38 | code: generateRandomOTP(config.otp.length), 39 | expiresAt: new Date(Date.now() + config.otp.expiration), 40 | user: user.id, 41 | purpose, 42 | }); 43 | 44 | const mailResponse = await MailServiceUtilities.sendOtp({ 45 | to: user.email, 46 | code: otp.code, 47 | purpose, 48 | }); 49 | 50 | if (!mailResponse.success) { 51 | throw mailResponse.error; 52 | } 53 | 54 | return { success: true, document: otp }; 55 | } catch (error) { 56 | return { 57 | success: false, 58 | error: 59 | error instanceof ErrorResponse 60 | ? error 61 | : new ErrorResponse( 62 | 'INTERNAL_SERVER_ERROR', 63 | (error as Error).message, 64 | ), 65 | }; 66 | } 67 | } 68 | 69 | async validate( 70 | email: string, 71 | code: string, 72 | purpose: TOTPPurpose, 73 | ): Promise | ErrorResponseType> { 74 | try { 75 | const userResponse = (await UserService.findOne({ 76 | email, 77 | })) as SuccessResponseType; 78 | if (!userResponse.success || !userResponse.document) { 79 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 80 | } 81 | 82 | const user = userResponse.document; 83 | const otpResponse = await this.repository.findValidCodeByUser( 84 | code, 85 | user.id, 86 | purpose, 87 | ); 88 | 89 | const invalidOtpError = new ErrorResponse( 90 | 'UNAUTHORIZED', 91 | 'This OTP code is invalid or has expired.', 92 | ); 93 | 94 | if (!otpResponse) { 95 | throw invalidOtpError; 96 | } 97 | 98 | const otp = otpResponse; 99 | if (await this.repository.isExpired(otp)) { 100 | throw invalidOtpError; 101 | } 102 | 103 | await this.repository.markAsUsed(otp.id); 104 | 105 | return { success: true }; 106 | } catch (error) { 107 | return { 108 | success: false, 109 | error: 110 | error instanceof ErrorResponse 111 | ? error 112 | : new ErrorResponse( 113 | 'INTERNAL_SERVER_ERROR', 114 | (error as Error).message, 115 | ), 116 | }; 117 | } 118 | } 119 | } 120 | 121 | export default new OTPService(); 122 | -------------------------------------------------------------------------------- /src/apps/auth/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './otp'; 2 | -------------------------------------------------------------------------------- /src/apps/auth/types/otp.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from 'mongoose'; 2 | import { config } from '../../../core/config'; 3 | import { IBaseModel } from '../../../core/engine'; 4 | 5 | export type TOTPPurpose = keyof typeof config.otp.purposes; 6 | 7 | export interface IOTP { 8 | code: string; 9 | user: Types.ObjectId; 10 | used: boolean; 11 | isFresh: boolean; 12 | expiresAt: Date; 13 | purpose: TOTPPurpose; 14 | attempts?: number; 15 | } 16 | 17 | export interface IOTPModel extends IOTP, IBaseModel, Document {} 18 | -------------------------------------------------------------------------------- /src/apps/auth/validators/auth.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const registerSchema = Joi.object({ 4 | firstname: Joi.string().required(), 5 | lastname: Joi.string().required(), 6 | email: Joi.string().email().required(), 7 | password: Joi.string().min(8).required(), 8 | profilePhoto: Joi.string().optional(), 9 | }).unknown(false); 10 | 11 | export const verifyAccountSchema = Joi.object({ 12 | email: Joi.string().email().required(), 13 | code: Joi.string().required(), 14 | }).unknown(false); 15 | 16 | export const generateLoginOtpSchema = Joi.object({ 17 | email: Joi.string().email().required(), 18 | }).unknown(false); 19 | 20 | export const loginWithPasswordSchema = Joi.object({ 21 | email: Joi.string().email().required(), 22 | password: Joi.string().required(), 23 | }).unknown(false); 24 | 25 | export const loginWithOtpSchema = Joi.object({ 26 | email: Joi.string().email().required(), 27 | code: Joi.string().required(), 28 | }).unknown(false); 29 | 30 | export const forgotPasswordSchema = Joi.object({ 31 | email: Joi.string().email().required(), 32 | }).unknown(false); 33 | 34 | export const resetPasswordSchema = Joi.object({ 35 | email: Joi.string().email().required(), 36 | code: Joi.string().required(), 37 | newPassword: Joi.string().min(8).required(), 38 | }).unknown(false); 39 | 40 | export const refreshSchema = Joi.object({ 41 | refreshToken: Joi.string().required(), 42 | }).unknown(false); 43 | 44 | export const logoutSchema = Joi.object({ 45 | accessToken: Joi.string().required(), 46 | refreshToken: Joi.string().required(), 47 | }).unknown(false); 48 | -------------------------------------------------------------------------------- /src/apps/auth/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | -------------------------------------------------------------------------------- /src/apps/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './starter'; 3 | export * from './users'; 4 | -------------------------------------------------------------------------------- /src/apps/starter/controllers/app.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { listRoutes } from '../../../helpers'; 4 | import { ViewService } from '../../../common/shared'; 5 | 6 | class AppController { 7 | static async showHomePage( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | ): Promise { 12 | try { 13 | const viewService = new ViewService(); 14 | const routes = listRoutes(req.app); 15 | req.flash('error', 'Error msg sample : une erreur est survenue.'); 16 | req.flash('success', 'Success msg sample : Successfully added.'); 17 | viewService.renderPage(req, res, 'index', { routes }); 18 | } catch (error) { 19 | const viewService = new ViewService(); 20 | viewService.renderErrorPage(req, res, 500, 'Internal Server Error'); 21 | } 22 | } 23 | } 24 | 25 | export default AppController; 26 | -------------------------------------------------------------------------------- /src/apps/starter/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppController } from './app.controller'; 2 | -------------------------------------------------------------------------------- /src/apps/starter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | export * from './routes'; 3 | -------------------------------------------------------------------------------- /src/apps/starter/routes/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { AppController } from '../controllers'; 3 | 4 | const router = Router(); 5 | 6 | router.get('/', AppController.showHomePage); 7 | 8 | export default router; 9 | -------------------------------------------------------------------------------- /src/apps/starter/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppRoutes } from './app.routes'; 2 | -------------------------------------------------------------------------------- /src/apps/users/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UserController } from './user.controller'; 2 | -------------------------------------------------------------------------------- /src/apps/users/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { UserService } from '../services'; 4 | import { ApiResponse, ErrorResponseType } from '../../../common/shared'; 5 | 6 | class UserController { 7 | static async createUser( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction, 11 | ): Promise { 12 | try { 13 | const response = await UserService.create(req.body); 14 | if (response.success) { 15 | ApiResponse.success(res, response, 201); 16 | } else { 17 | throw response; 18 | } 19 | } catch (error) { 20 | ApiResponse.error(res, error as ErrorResponseType); 21 | } 22 | } 23 | 24 | static async getAllUsers( 25 | req: Request, 26 | res: Response, 27 | next: NextFunction, 28 | ): Promise { 29 | try { 30 | const response = await UserService.findAll(req.query); 31 | if (response.success) { 32 | ApiResponse.success(res, response); 33 | } else { 34 | throw response; 35 | } 36 | } catch (error) { 37 | ApiResponse.error(res, error as ErrorResponseType); 38 | } 39 | } 40 | 41 | static async getUserById( 42 | req: Request, 43 | res: Response, 44 | next: NextFunction, 45 | ): Promise { 46 | try { 47 | const userId = req.params.id; 48 | const response = await UserService.findOne({ 49 | _id: userId, 50 | }); 51 | 52 | if (response.success) { 53 | ApiResponse.success(res, response); 54 | } else { 55 | throw response; 56 | } 57 | } catch (error) { 58 | ApiResponse.error(res, error as ErrorResponseType); 59 | } 60 | } 61 | 62 | static async getCurrentUser( 63 | req: Request, 64 | res: Response, 65 | next: NextFunction, 66 | ): Promise { 67 | try { 68 | const userId = (req as any).payload?.aud as string; 69 | const response = await UserService.getProfile(userId); 70 | 71 | if (response.success) { 72 | ApiResponse.success(res, response); 73 | } else { 74 | throw response; 75 | } 76 | } catch (error) { 77 | ApiResponse.error(res, error as ErrorResponseType); 78 | } 79 | } 80 | } 81 | 82 | export default UserController; 83 | -------------------------------------------------------------------------------- /src/apps/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controllers'; 2 | export * from './models'; 3 | export * from './repositories'; 4 | export * from './routes'; 5 | export * from './services'; 6 | export * from './types'; 7 | export * from './validators'; 8 | -------------------------------------------------------------------------------- /src/apps/users/models/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UserModel } from './user.model'; 2 | -------------------------------------------------------------------------------- /src/apps/users/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { CallbackError } from 'mongoose'; 2 | import bcrypt from 'bcrypt'; 3 | import { IUserModel } from '../types'; 4 | import { BaseModel, createBaseSchema } from '../../../core/engine'; 5 | import { config } from '../../../core/config'; 6 | 7 | const USER_MODEL_NAME = 'User'; 8 | 9 | const UserSchema = createBaseSchema( 10 | { 11 | firstname: { type: String, required: true }, 12 | lastname: { type: String, required: true }, 13 | email: { type: String, required: true, unique: true }, 14 | password: { type: String, required: true }, 15 | role: { type: String, enum: ['admin', 'user', 'guest'], default: 'user' }, 16 | profilePhoto: { type: String }, 17 | active: { type: Boolean, default: true }, 18 | verified: { type: Boolean, default: false }, 19 | }, 20 | { 21 | modelName: USER_MODEL_NAME, 22 | }, 23 | ); 24 | 25 | UserSchema.pre('save', async function (next) { 26 | try { 27 | if (this.isNew || this.isModified('password')) { 28 | const salt = await bcrypt.genSalt(config.bcrypt.saltRounds); 29 | const hashedPassword = await bcrypt.hash(this.password, salt); 30 | this.password = hashedPassword; 31 | } 32 | next(); 33 | } catch (error) { 34 | next(error as CallbackError); 35 | } 36 | }); 37 | 38 | const UserModel = new BaseModel( 39 | USER_MODEL_NAME, 40 | UserSchema, 41 | ).getModel(); 42 | 43 | export default UserModel; 44 | -------------------------------------------------------------------------------- /src/apps/users/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UserRepository } from './user.repo'; 2 | -------------------------------------------------------------------------------- /src/apps/users/repositories/user.repo.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'mongoose'; 2 | import { IUserModel } from '../types'; 3 | import { BaseRepository } from '../../../core/engine'; 4 | 5 | export class UserRepository extends BaseRepository { 6 | constructor(model: Model) { 7 | super(model); 8 | } 9 | } 10 | 11 | export default UserRepository; 12 | -------------------------------------------------------------------------------- /src/apps/users/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UserRoutes } from './user.routes'; 2 | -------------------------------------------------------------------------------- /src/apps/users/routes/user.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { 3 | authenticateAndAttachUserContext, 4 | validate, 5 | } from '../../../common/shared'; 6 | import { createUserSchema } from '../validators'; 7 | import { UserController } from '../controllers'; 8 | const router = Router(); 9 | 10 | router.post( 11 | '/', 12 | authenticateAndAttachUserContext, 13 | validate(createUserSchema), 14 | UserController.createUser, 15 | ); 16 | router.get('/', UserController.getAllUsers); 17 | router.get( 18 | '/current', 19 | authenticateAndAttachUserContext, 20 | UserController.getCurrentUser, 21 | ); 22 | router.get('/:id', UserController.getUserById); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /src/apps/users/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UserService } from './user.service'; 2 | -------------------------------------------------------------------------------- /src/apps/users/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../../../core/config'; 2 | import bcrypt from 'bcrypt'; 3 | import { 4 | ErrorResponse, 5 | ErrorResponseType, 6 | SuccessResponseType, 7 | } from '../../../common/shared'; 8 | import { IUserModel } from '../types'; 9 | import { UserModel } from '../models'; 10 | import { UserRepository } from '../repositories'; 11 | import { BaseService } from '../../../core/engine'; 12 | 13 | class UserService extends BaseService { 14 | constructor() { 15 | const userRepo = new UserRepository(UserModel); 16 | super(userRepo, true /*, ['profilePicture']*/); 17 | this.searchFields = ['firstName', 'lastName', 'email']; 18 | } 19 | 20 | async isValidPassword( 21 | userId: string, 22 | password: string, 23 | ): Promise | ErrorResponseType> { 24 | try { 25 | const response = (await this.findOne({ 26 | _id: userId, 27 | })) as SuccessResponseType; 28 | if (!response.success || !response.document) { 29 | throw response.error; 30 | } 31 | 32 | const isValid = await bcrypt.compare( 33 | password, 34 | response.document.password, 35 | ); 36 | return { success: true, document: { isValid } }; 37 | } catch (error) { 38 | return { 39 | success: false, 40 | error: 41 | error instanceof ErrorResponse 42 | ? error 43 | : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), 44 | }; 45 | } 46 | } 47 | 48 | async updatePassword( 49 | userId: string, 50 | newPassword: string, 51 | ): Promise | ErrorResponseType> { 52 | try { 53 | const response = (await this.findOne({ 54 | _id: userId, 55 | })) as SuccessResponseType; 56 | if (!response.success || !response.document) { 57 | throw response.error; 58 | } 59 | 60 | const hashedPassword = await bcrypt.hash( 61 | newPassword, 62 | config.bcrypt.saltRounds, 63 | ); 64 | const updateResponse = (await this.update( 65 | { _id: userId }, 66 | { password: hashedPassword }, 67 | )) as SuccessResponseType; 68 | 69 | if (!updateResponse.success) { 70 | throw updateResponse.error; 71 | } 72 | 73 | return { 74 | success: true, 75 | document: updateResponse.document, 76 | }; 77 | } catch (error) { 78 | return { 79 | success: false, 80 | error: 81 | error instanceof ErrorResponse 82 | ? error 83 | : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), 84 | }; 85 | } 86 | } 87 | 88 | async isVerified( 89 | email: string, 90 | ): Promise | ErrorResponseType> { 91 | try { 92 | const response = (await this.findOne({ 93 | email, 94 | })) as SuccessResponseType; 95 | if (!response.success || !response.document) { 96 | throw response.error; 97 | } 98 | 99 | return { 100 | success: true, 101 | document: { verified: response.document.verified }, 102 | }; 103 | } catch (error) { 104 | return { 105 | success: false, 106 | error: 107 | error instanceof ErrorResponse 108 | ? error 109 | : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), 110 | }; 111 | } 112 | } 113 | 114 | async markAsVerified( 115 | email: string, 116 | ): Promise | ErrorResponseType> { 117 | try { 118 | const response = (await this.findOne({ 119 | email, 120 | })) as SuccessResponseType; 121 | if (!response.success || !response.document) { 122 | throw response.error; 123 | } 124 | 125 | const updateResponse = (await this.update( 126 | { _id: response.document._id }, 127 | { verified: true }, 128 | )) as SuccessResponseType; 129 | 130 | if (!updateResponse.success) { 131 | throw updateResponse.error; 132 | } 133 | 134 | return { 135 | success: true, 136 | document: updateResponse.document, 137 | }; 138 | } catch (error) { 139 | return { 140 | success: false, 141 | error: 142 | error instanceof ErrorResponse 143 | ? error 144 | : new ErrorResponse('UNKNOWN_ERROR', (error as Error).message), 145 | }; 146 | } 147 | } 148 | 149 | async getProfile( 150 | userId?: string | undefined, 151 | ): Promise | ErrorResponseType> { 152 | try { 153 | if (!userId) { 154 | throw new ErrorResponse('BAD_REQUEST', 'User ID is required.'); 155 | } 156 | 157 | const user = (await this.findOne({ 158 | _id: userId, 159 | })) as SuccessResponseType; 160 | 161 | if (!user.success || !user.document) { 162 | throw new ErrorResponse('NOT_FOUND_ERROR', 'User not found.'); 163 | } 164 | 165 | return { 166 | success: true, 167 | document: { 168 | firstname: user.document.firstname, 169 | lastname: user.document.lastname, 170 | email: user.document.email, 171 | verified: user.document.verified, 172 | active: user.document.active, 173 | role: user.document.role, 174 | } as any, // As we are not sending user password, we need to mention any here to avoid type check error 175 | }; 176 | } catch (error) { 177 | return { 178 | success: false, 179 | error: 180 | error instanceof ErrorResponse 181 | ? error 182 | : new ErrorResponse( 183 | 'INTERNAL_SERVER_ERROR', 184 | (error as Error).message, 185 | ), 186 | }; 187 | } 188 | } 189 | } 190 | 191 | export default new UserService(); 192 | -------------------------------------------------------------------------------- /src/apps/users/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user'; 2 | -------------------------------------------------------------------------------- /src/apps/users/types/user.ts: -------------------------------------------------------------------------------- 1 | // src/apps/users/types/user.ts 2 | 3 | import { Document } from 'mongoose'; 4 | import { IBaseModel } from '../../../core/engine'; // Importer IBaseModel pour l'extension 5 | 6 | export type TUserRole = 'admin' | 'user' | 'guest'; 7 | 8 | export interface IUser extends IBaseModel { 9 | firstname: string; 10 | lastname: string; 11 | email: string; 12 | password: string; 13 | role: TUserRole; 14 | profilePhoto?: string; 15 | verified: boolean; 16 | active: boolean; 17 | } 18 | 19 | export interface IUserModel extends IUser, IBaseModel, Document {} 20 | -------------------------------------------------------------------------------- /src/apps/users/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user'; 2 | -------------------------------------------------------------------------------- /src/apps/users/validators/user.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const createUserSchema = Joi.object({ 4 | firstname: Joi.string().required(), 5 | lastname: Joi.string().required(), 6 | email: Joi.string().email().required(), 7 | password: Joi.string().min(8).required(), 8 | role: Joi.string().valid('admin', 'user', 'guest').optional(), 9 | profilePhoto: Joi.string().optional(), 10 | }); 11 | -------------------------------------------------------------------------------- /src/common/global-router/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { AppRoutes, AuthRoutes, OTPRoutes, UserRoutes } from '../../apps'; 3 | 4 | const router = Router(); 5 | 6 | router.use('/', AppRoutes); 7 | router.use('/users', UserRoutes); 8 | router.use('/otp', OTPRoutes); 9 | router.use('/auth', AuthRoutes); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /src/common/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './utils'; 3 | export * from './services'; 4 | export * from './middlewares'; 5 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/attach-user-to-context.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { AsyncStorageService, logger } from '../services'; 3 | 4 | export const attachUserToContext = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction, 8 | ) => { 9 | const asyncStorage = AsyncStorageService.getInstance(); 10 | // @ts-ignore: Suppress TS error for non-existent property 11 | const payload = req.payload; 12 | if (payload && typeof payload.aud === 'string') { 13 | const userId = payload.aud; 14 | 15 | asyncStorage.run(() => { 16 | asyncStorage.set('currentUserId', userId); 17 | next(); 18 | }); 19 | } else { 20 | logger.warn( 21 | 'Warning: Unable to attach user context, missing payload or audience field.', 22 | ); 23 | next(); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/authenticate-req-with-user-attach.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { JwtService, AsyncStorageService, logger } from '../services'; 3 | 4 | export const authenticateAndAttachUserContext = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction, 8 | ) => { 9 | JwtService.verifyAccessToken(req, res, (authErr: any) => { 10 | if (authErr) { 11 | return next(authErr); 12 | } 13 | 14 | // @ts-ignore: Suppress TS error for non-existent property 15 | const payload = req.payload; 16 | 17 | if (payload && typeof payload.aud === 'string') { 18 | const userId = payload.aud; 19 | const asyncStorage = AsyncStorageService.getInstance(); 20 | 21 | asyncStorage.run(() => { 22 | asyncStorage.set('currentUserId', userId); 23 | next(); 24 | }); 25 | } else { 26 | logger.warn( 27 | 'Warning: Unable to attach user context, missing payload or audience field.', 28 | ); 29 | next(); 30 | } 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/authenticate-request.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { JwtService } from '../services'; 3 | 4 | const authenticateRequest = ( 5 | req: Request, 6 | res: Response, 7 | next: NextFunction, 8 | ) => { 9 | JwtService.verifyAccessToken(req, res, next); 10 | }; 11 | 12 | export default authenticateRequest; 13 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/bruteforce.ts: -------------------------------------------------------------------------------- 1 | import { RateLimiterMongo } from 'rate-limiter-flexible'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import { config } from '../../../core/config'; 4 | import { DB } from '../../../core/framework'; 5 | import { logger } from '../services'; 6 | 7 | let bruteForceLimiter: RateLimiterMongo | undefined; 8 | 9 | const setupRateLimiter = async (): Promise => { 10 | try { 11 | await DB.mongo.init(config.db.uri, config.db.name); 12 | const mongoConn = await DB.mongo.getClient(); 13 | 14 | bruteForceLimiter = new RateLimiterMongo({ 15 | storeClient: mongoConn, 16 | points: config.bruteForce.freeRetries, // Nombre de tentatives autorisées 17 | duration: Math.ceil(config.bruteForce.lifetime / 1000), // Durée de vie en secondes 18 | blockDuration: Math.ceil(config.bruteForce.maxWait / 1000), // Durée de blocage en secondes 19 | }); 20 | 21 | logger.info('Rate limiter configured.'); 22 | } catch (error) { 23 | logger.error('Error setting up rate limiter', error as any); 24 | } 25 | }; 26 | 27 | setupRateLimiter(); 28 | 29 | const bruteForceMiddleware = async ( 30 | req: Request, 31 | res: Response, 32 | next: NextFunction, 33 | ): Promise => { 34 | if (!bruteForceLimiter) { 35 | const error = new Error('Rate limiter not configured yet.'); 36 | logger.error(error.message, error); 37 | res.status(500).json({ 38 | message: 'Rate limiter not configured yet. Please try again later.', 39 | }); 40 | return; 41 | } 42 | 43 | try { 44 | await bruteForceLimiter.consume(req.ip as string); 45 | next(); 46 | } catch (rejRes: any) { 47 | const retrySecs = Math.ceil(rejRes.msBeforeNext / 1000) || 1; 48 | if (!config.runningProd) { 49 | res.set('Retry-After', String(retrySecs)); // Send Retry-After only in dev mode 50 | } 51 | logger.warn( 52 | ` Too many attempts from IP: ${req.ip}. Retry after ${retrySecs} seconds.`, 53 | ); 54 | res.status(429).json({ 55 | message: ` Too many attempts, please try again after ${Math.ceil(rejRes.msBeforeNext / 60000)} minutes.`, 56 | }); 57 | } 58 | }; 59 | 60 | export default bruteForceMiddleware; 61 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/client-authentication.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { logger } from '../services'; 3 | import { config } from '../../../core/config'; 4 | 5 | export const clientAuthentication = ( 6 | req: Request, 7 | res: Response, 8 | next: NextFunction, 9 | ) => { 10 | const clientToken = req.headers['x-client-token'] as string; 11 | 12 | if (!clientToken) { 13 | logger.warn( 14 | `Unauthorized access attempt from IP: ${req.ip} - No client token provided`, 15 | ); 16 | return res.status(401).send('Unauthorized'); 17 | } 18 | 19 | const [username, password] = Buffer.from(clientToken, 'base64') 20 | .toString() 21 | .split(':'); 22 | 23 | const validUser = config.basicAuthUser; 24 | const validPass = config.basicAuthPass; 25 | 26 | if (username === validUser && password === validPass) { 27 | logger.info(`Client authenticated successfully from IP: ${req.ip}`); 28 | return next(); 29 | } else { 30 | logger.warn( 31 | `Forbidden access attempt from IP: ${req.ip} - Invalid credentials`, 32 | ); 33 | return res.status(403).send('Forbidden'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client-authentication'; 2 | export { default as authenticateRequest } from './authenticate-request'; 3 | export { default as bruteForceMiddleware } from './bruteforce'; 4 | export * from './rate-limiter'; 5 | export * from './validate'; 6 | export * from './authenticate-req-with-user-attach'; 7 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import { logger } from '../services'; 3 | import { config } from '../../../core/config'; 4 | 5 | // TODO: Remove this later and use the one that's in helpers 6 | const msToMinutes = (ms: number): number => { 7 | return Math.ceil(ms / 60000); 8 | }; 9 | 10 | export const apiRateLimiter = rateLimit({ 11 | windowMs: config.rate.limit, // Time window in milliseconds 12 | max: config.rate.max, // Maximum number of requests 13 | standardHeaders: !config.runningProd, // Show ratelimit headers when not in production 14 | message: ` Too many requests from this IP, please try again after ${msToMinutes(config.rate.limit)} minutes.`, 15 | handler: (req, res) => { 16 | logger.warn(`Too many requests from IP: ${req.ip}`); 17 | res.status(429).json({ 18 | message: ` Too many requests from this IP, please try again after ${msToMinutes(config.rate.limit)} minutes.`, 19 | }); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/common/shared/middlewares/validate.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { ObjectSchema } from 'joi'; 3 | import { ApiResponse, ErrorResponse } from '../utils'; 4 | 5 | export const validate = (schema: ObjectSchema) => { 6 | return (req: Request, res: Response, next: NextFunction) => { 7 | const { error } = schema.validate(req.body, { abortEarly: false }); 8 | if (error) { 9 | const { details } = error; 10 | const message = details.map((i) => i.message).join(','); 11 | const errorResponse = new ErrorResponse('VALIDATION_ERROR', message); 12 | return ApiResponse.error(res, { 13 | success: false, 14 | error: { 15 | message: errorResponse.message, 16 | suggestions: errorResponse.suggestions, 17 | statusCode: errorResponse.statusCode, 18 | } as any, 19 | }); 20 | } else { 21 | next(); 22 | } 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/common/shared/services/async-localstorage.service.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | 3 | export class AsyncStorageService { 4 | private static instance: AsyncStorageService; 5 | private storage: AsyncLocalStorage>; 6 | 7 | private constructor() { 8 | this.storage = new AsyncLocalStorage(); 9 | } 10 | 11 | public static getInstance(): AsyncStorageService { 12 | if (!AsyncStorageService.instance) { 13 | AsyncStorageService.instance = new AsyncStorageService(); 14 | } 15 | return AsyncStorageService.instance; 16 | } 17 | 18 | public set(key: string, value: any) { 19 | const store = this.storage.getStore(); 20 | if (store) { 21 | store.set(key, value); 22 | } 23 | } 24 | 25 | public get(key: string): any { 26 | const store = this.storage.getStore(); 27 | return store ? store.get(key) : undefined; 28 | } 29 | 30 | public run(callback: () => void, initialValue?: Map) { 31 | this.storage.run(initialValue || new Map(), callback); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/common/shared/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as JwtService } from './jwt.service'; 2 | export { default as ViewService } from './view.service'; 3 | export * from './mail'; 4 | export * from './logger.service'; 5 | export * from './async-localstorage.service'; 6 | -------------------------------------------------------------------------------- /src/common/shared/services/jwt.service.ts: -------------------------------------------------------------------------------- 1 | import JWT, { SignOptions } from 'jsonwebtoken'; 2 | import { ApiResponse, ErrorResponse } from '../utils'; 3 | import { DB } from '../../../core/framework'; 4 | import { logger } from './logger.service'; 5 | import { config } from '../../../core/config'; 6 | 7 | const redis = DB.redis; 8 | 9 | redis.init(); 10 | const client = redis.getClient(); 11 | 12 | class JwtService { 13 | private accessTokenSecret: string; 14 | private refreshTokenSecret: string; 15 | private accessTokenExpireTime: string; 16 | private refreshTokenExpireTime: string; 17 | private tokenIssuer: string; 18 | private redisTokenExpireTime: number; 19 | private redisBlacklistExpireTime: number; 20 | 21 | constructor() { 22 | this.accessTokenSecret = config.jwt.accessTokenSecret; 23 | this.refreshTokenSecret = config.jwt.refreshTokenSecret; 24 | this.accessTokenExpireTime = config.jwt.accessTokenExpireTime; 25 | this.refreshTokenExpireTime = config.jwt.refreshTokenExpireTime; 26 | this.tokenIssuer = config.jwt.tokenIssuer; 27 | this.redisTokenExpireTime = config.redis.tokenExpireTime; 28 | this.redisBlacklistExpireTime = config.redis.blacklistExpireTime; 29 | } 30 | 31 | signAccessToken(userId: string): Promise { 32 | return new Promise((resolve, reject) => { 33 | const payload = {}; 34 | const options: SignOptions = { 35 | expiresIn: this.accessTokenExpireTime, 36 | issuer: this.tokenIssuer, 37 | audience: userId, 38 | }; 39 | 40 | JWT.sign( 41 | payload, 42 | this.accessTokenSecret, 43 | options, 44 | (err: any, token?: string) => { 45 | if (err || !token) { 46 | logger.error(err?.message, err); 47 | const errorResponse = new ErrorResponse( 48 | 'INTERNAL_SERVER_ERROR', 49 | 'Internal Server Error', 50 | ); 51 | return reject(errorResponse); 52 | } 53 | resolve(token); 54 | }, 55 | ); 56 | }); 57 | } 58 | 59 | async isTokenBlacklisted(token: string): Promise { 60 | return new Promise((resolve, reject) => { 61 | client.get(`bl_${token}`, (err: any, result: any) => { 62 | if (err) { 63 | logger.error(err.message, err); 64 | const errorResponse = new ErrorResponse( 65 | 'INTERNAL_SERVER_ERROR', 66 | 'Internal Server Error', 67 | ); 68 | return reject(errorResponse); 69 | } 70 | resolve(result === 'blacklisted'); 71 | }); 72 | }); 73 | } 74 | 75 | verifyAccessToken(req: any, res: any, next: any): void { 76 | if (!req.headers['authorization']) { 77 | const errorResponse = new ErrorResponse('UNAUTHORIZED', 'Unauthorized', [ 78 | 'No authorization header', 79 | ]); 80 | return ApiResponse.error(res, { 81 | success: false, 82 | error: errorResponse, 83 | }) as any; 84 | } 85 | 86 | const authHeader = req.headers['authorization']; 87 | const bearerToken = authHeader.split(' '); 88 | const token = bearerToken[1]; 89 | JWT.verify( 90 | token, 91 | this.accessTokenSecret, 92 | async (err: any, payload: any) => { 93 | if (err) { 94 | const message = 95 | err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message; 96 | const errorResponse = new ErrorResponse('UNAUTHORIZED', message); 97 | return ApiResponse.error(res, { 98 | success: false, 99 | error: errorResponse, 100 | }); 101 | } 102 | 103 | try { 104 | const blacklisted = await this.isTokenBlacklisted(token); 105 | if (blacklisted) { 106 | const errorResponse = new ErrorResponse('FORBIDDEN', 'Forbidden', [ 107 | 'Token is blacklisted', 108 | ]); 109 | return ApiResponse.error(res, { 110 | success: false, 111 | error: errorResponse, 112 | }); 113 | } 114 | } catch (error) { 115 | return ApiResponse.error(res, { 116 | success: false, 117 | error: error as ErrorResponse, 118 | }); 119 | } 120 | 121 | req.payload = payload; 122 | next(); 123 | }, 124 | ); 125 | } 126 | 127 | signRefreshToken(userId: string): Promise { 128 | return new Promise((resolve, reject) => { 129 | const payload = {}; 130 | const options: SignOptions = { 131 | expiresIn: this.refreshTokenExpireTime, 132 | issuer: this.tokenIssuer, 133 | audience: userId, 134 | }; 135 | 136 | JWT.sign( 137 | payload, 138 | this.refreshTokenSecret, 139 | options, 140 | (err: any, token?: string) => { 141 | if (err || !token) { 142 | logger.error(err?.message, err); 143 | const errorResponse = new ErrorResponse( 144 | 'INTERNAL_SERVER_ERROR', 145 | 'Internal Server Error', 146 | ); 147 | return reject(errorResponse); 148 | } 149 | 150 | client.set( 151 | userId, 152 | token, 153 | 'EX', 154 | this.redisTokenExpireTime, 155 | (redisErr: any) => { 156 | if (redisErr) { 157 | logger.error(redisErr.message, redisErr); 158 | const errorResponse = new ErrorResponse( 159 | 'INTERNAL_SERVER_ERROR', 160 | 'Internal Server Error', 161 | ); 162 | return reject(errorResponse); 163 | } 164 | resolve(token); 165 | }, 166 | ); 167 | }, 168 | ); 169 | }); 170 | } 171 | 172 | verifyRefreshToken(refreshToken: string): Promise { 173 | return new Promise((resolve, reject) => { 174 | JWT.verify( 175 | refreshToken, 176 | this.refreshTokenSecret, 177 | (err: any, payload: any) => { 178 | if (err) { 179 | const errorResponse = new ErrorResponse( 180 | 'UNAUTHORIZED', 181 | 'Unauthorized', 182 | ); 183 | return reject(errorResponse); 184 | } 185 | 186 | const userId = payload?.aud as string; 187 | 188 | client.get(userId, (redisErr: any, result: any) => { 189 | if (redisErr) { 190 | logger.error(redisErr.message, redisErr); 191 | const errorResponse = new ErrorResponse( 192 | 'INTERNAL_SERVER_ERROR', 193 | 'Internal Server Error', 194 | ); 195 | return reject(errorResponse); 196 | } 197 | 198 | if (refreshToken === result) { 199 | return resolve(userId); 200 | } 201 | 202 | const errorResponse = new ErrorResponse( 203 | 'UNAUTHORIZED', 204 | 'Unauthorized', 205 | ); 206 | return reject(errorResponse); 207 | }); 208 | }, 209 | ); 210 | }); 211 | } 212 | 213 | blacklistToken(token: string): Promise { 214 | return new Promise((resolve, reject) => { 215 | client.set( 216 | `bl_${token}`, 217 | 'blacklisted', 218 | 'EX', 219 | this.redisBlacklistExpireTime, 220 | (redisErr: any) => { 221 | if (redisErr) { 222 | logger.error(redisErr.message, redisErr); 223 | const errorResponse = new ErrorResponse( 224 | 'INTERNAL_SERVER_ERROR', 225 | 'Internal Server Error', 226 | ); 227 | return reject(errorResponse); 228 | } 229 | resolve(); 230 | }, 231 | ); 232 | }); 233 | } 234 | 235 | removeFromRedis(key: string): Promise { 236 | return new Promise((resolve, reject) => { 237 | client.del(key, (redisErr: any) => { 238 | if (redisErr) { 239 | logger.error(redisErr.message, redisErr); 240 | const errorResponse = new ErrorResponse( 241 | 'INTERNAL_SERVER_ERROR', 242 | 'Internal Server Error', 243 | ); 244 | return reject(errorResponse); 245 | } 246 | resolve(); 247 | }); 248 | }); 249 | } 250 | 251 | checkAccessToken(accessToken: string): Promise<{ userId: string }> { 252 | return new Promise((resolve, reject) => { 253 | JWT.verify( 254 | accessToken, 255 | this.accessTokenSecret, 256 | (err: any, payload: any) => { 257 | if (err) { 258 | const message = 259 | err.name === 'JsonWebTokenError' ? 'Unauthorized' : err.message; 260 | const errorResponse = new ErrorResponse('UNAUTHORIZED', message); 261 | return reject(errorResponse); 262 | } 263 | 264 | const userId = payload?.aud as string; 265 | 266 | resolve({ userId }); 267 | }, 268 | ); 269 | }); 270 | } 271 | 272 | checkRefreshToken(refreshToken: string): Promise<{ userId: string }> { 273 | return new Promise((resolve, reject) => { 274 | JWT.verify( 275 | refreshToken, 276 | this.refreshTokenSecret, 277 | (err: any, payload: any) => { 278 | if (err) { 279 | const errorResponse = new ErrorResponse( 280 | 'UNAUTHORIZED', 281 | 'Unauthorized', 282 | ); 283 | return reject(errorResponse); 284 | } 285 | 286 | const userId = payload?.aud as string; 287 | 288 | client.get(userId, (redisErr: any, result: any) => { 289 | if (redisErr) { 290 | logger.error(redisErr.message, redisErr); 291 | const errorResponse = new ErrorResponse( 292 | 'INTERNAL_SERVER_ERROR', 293 | 'Internal Server Error', 294 | ); 295 | return reject(errorResponse); 296 | } 297 | 298 | if (refreshToken === result) { 299 | return resolve({ userId }); 300 | } 301 | 302 | const errorResponse = new ErrorResponse( 303 | 'UNAUTHORIZED', 304 | 'Unauthorized', 305 | ); 306 | return reject(errorResponse); 307 | }); 308 | }, 309 | ); 310 | }); 311 | } 312 | } 313 | 314 | export default new JwtService(); 315 | -------------------------------------------------------------------------------- /src/common/shared/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports, Logger } from 'winston'; 2 | import { Format } from 'logform'; 3 | 4 | class LoggerService { 5 | private logger: Logger; 6 | 7 | constructor() { 8 | const logFormat: Format = format.combine( 9 | format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 10 | format.printf( 11 | (info) => `[${info.timestamp}] (${info.level}): ${info.message}`, 12 | ), 13 | ); 14 | 15 | this.logger = createLogger({ 16 | level: 'info', 17 | format: logFormat, 18 | transports: [ 19 | new transports.Console({ 20 | format: format.combine(format.colorize(), logFormat), 21 | }), 22 | new transports.File({ filename: 'logs/error.log', level: 'error' }), 23 | new transports.File({ filename: 'logs/combined.log' }), 24 | ], 25 | exceptionHandlers: [ 26 | new transports.File({ filename: 'logs/exceptions.log' }), 27 | ], 28 | }); 29 | 30 | // Environments other than production 31 | if (process.env.NODE_ENV !== 'production') { 32 | this.logger.add( 33 | new transports.Console({ 34 | format: format.combine(format.colorize(), logFormat), 35 | }), 36 | ); 37 | } 38 | } 39 | 40 | log(level: string, message: string, metadata?: Record): void { 41 | this.logger.log({ level, message, ...metadata }); 42 | } 43 | 44 | info(message: string, metadata?: Record): void { 45 | this.logger.info(message, metadata); 46 | } 47 | 48 | warn(message: string, metadata?: Record): void { 49 | this.logger.warn(message, metadata); 50 | } 51 | 52 | error(message: string, error?: Error): void { 53 | this.logger.error(message, { error: error?.stack || error }); 54 | } 55 | } 56 | 57 | export const logger = new LoggerService(); 58 | -------------------------------------------------------------------------------- /src/common/shared/services/mail/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MailService } from './mail.service'; 2 | export { default as MailServiceUtilities } from './mail.service.utility'; 3 | -------------------------------------------------------------------------------- /src/common/shared/services/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { Transporter } from 'nodemailer'; 2 | import handlebars from 'handlebars'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { ErrorResponse } from '../../utils'; 6 | import { ErrorResponseType, SuccessResponseType } from '../../types'; 7 | import { logger } from '..'; 8 | import { config } from '../../../../core/config'; 9 | 10 | class MailService { 11 | private transporter: Transporter; 12 | 13 | constructor() { 14 | this.transporter = nodemailer.createTransport({ 15 | host: config.mail.host, 16 | port: config.mail.port, 17 | secure: config.runningProd && config.mail.port === 465, // true for 465, false for other ports 18 | auth: config.runningProd 19 | ? { 20 | user: config.mail.user, 21 | pass: config.mail.pass, 22 | } 23 | : undefined, 24 | }); 25 | } 26 | 27 | async sendMail({ 28 | to, 29 | subject, 30 | text, 31 | htmlTemplate, 32 | templateData, 33 | fromName, 34 | fromEmail, 35 | }: { 36 | to: string; 37 | subject: string; 38 | text?: string; 39 | htmlTemplate?: string; 40 | templateData?: Record; 41 | fromName?: string; 42 | fromEmail?: string; 43 | }): Promise | ErrorResponseType> { 44 | try { 45 | let htmlContent; 46 | if (htmlTemplate) { 47 | const templatePath = path.join( 48 | __dirname, 49 | '../../../templates/mail', 50 | `${htmlTemplate}.html`, 51 | ); 52 | const templateSource = fs.readFileSync(templatePath, 'utf-8'); 53 | const template = handlebars.compile(templateSource); 54 | htmlContent = template(templateData); 55 | } 56 | 57 | const mailOptions = { 58 | from: `"${fromName || config.mail.fromName}" <${ 59 | fromEmail || config.mail.from 60 | }>`, 61 | to, 62 | subject, 63 | text, 64 | html: htmlContent, 65 | }; 66 | 67 | await this.transporter.sendMail(mailOptions); 68 | return { success: true }; 69 | } catch (error) { 70 | logger.error('Error sending email', error as Error); 71 | return { 72 | success: false, 73 | error: new ErrorResponse( 74 | 'INTERNAL_SERVER_ERROR', 75 | 'Failed to send email', 76 | ['Please try again later.'], 77 | error as Error, 78 | ), 79 | }; 80 | } 81 | } 82 | } 83 | 84 | export default new MailService(); 85 | -------------------------------------------------------------------------------- /src/common/shared/services/mail/mail.service.utility.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../../../../core/config'; 2 | import { ErrorResponseType, SuccessResponseType } from '../../types'; 3 | import { ErrorResponse } from '../../utils'; 4 | import MailService from './mail.service'; 5 | 6 | class MailServiceUtilities { 7 | static async sendOtp({ 8 | to, 9 | code, 10 | purpose, 11 | }: { 12 | to: string; 13 | code: string; 14 | purpose: string; 15 | }): Promise | ErrorResponseType> { 16 | const otpPurpose = config.otp.purposes[purpose]; 17 | if (!otpPurpose) { 18 | return { 19 | success: false, 20 | error: new ErrorResponse('BAD_REQUEST', 'Invalid OTP purpose provided'), 21 | }; 22 | } 23 | 24 | const subject = otpPurpose.title; 25 | const text = `${otpPurpose.message} ${code}\n\nThis code is valid for ${ 26 | config.otp.expiration / 60000 27 | } minutes.`; 28 | 29 | return await MailService.sendMail({ to, subject, text }); 30 | } 31 | 32 | static async sendAccountCreationEmail({ 33 | to, 34 | firstname, 35 | }: { 36 | to: string; 37 | firstname: string; 38 | }): Promise | ErrorResponseType> { 39 | const subject = 'Welcome to Our Service'; 40 | const htmlTemplate = 'welcome'; 41 | const templateData = { firstname }; 42 | 43 | return await MailService.sendMail({ 44 | to, 45 | subject, 46 | htmlTemplate, 47 | templateData, 48 | }); 49 | } 50 | } 51 | 52 | export default MailServiceUtilities; 53 | -------------------------------------------------------------------------------- /src/common/shared/services/view.service.ts: -------------------------------------------------------------------------------- 1 | import { Application, Request, Response } from 'express'; 2 | 3 | class ViewService { 4 | // constructor(app: Application) { 5 | // this.setup(app); 6 | // } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | private setup(app: Application) { 10 | // 11 | } 12 | 13 | renderPage(req: Request, res: Response, view: string, options: any = {}) { 14 | res.render(view, { 15 | ...options, 16 | successMessages: req.flash('success'), 17 | errorMessages: req.flash('error'), 18 | }); 19 | } 20 | 21 | renderErrorPage( 22 | req: Request, 23 | res: Response, 24 | statusCode: number, 25 | message: string, 26 | ) { 27 | const view = `errors/${statusCode}`; 28 | res.status(statusCode).render(view, { 29 | message, 30 | successMessages: req.flash('success'), 31 | errorMessages: req.flash('error'), 32 | }); 33 | } 34 | 35 | redirectWithFlash( 36 | req: Request, 37 | res: Response, 38 | route: string, 39 | message: string, 40 | type: 'success' | 'error', 41 | ) { 42 | req.flash(type, message); 43 | res.redirect(route); 44 | } 45 | } 46 | 47 | export default ViewService; 48 | -------------------------------------------------------------------------------- /src/common/shared/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from 'jsonwebtoken'; 2 | 3 | declare module 'express-serve-static-core' { 4 | interface Request { 5 | payload?: JwtPayload; 6 | mongooseOptions?: Record; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/common/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './service-response'; 2 | -------------------------------------------------------------------------------- /src/common/shared/types/service-response.ts: -------------------------------------------------------------------------------- 1 | import { ErrorResponse } from '../utils'; 2 | 3 | export type SuccessResponseType = { 4 | success: boolean; 5 | document?: T; 6 | documents?: T[]; 7 | total?: number; 8 | results?: number; 9 | page?: number; 10 | limit?: number; 11 | _results?: number; 12 | error?: ErrorResponse; 13 | }; 14 | 15 | export type ErrorResponseType = { 16 | success: boolean; 17 | error: ErrorResponse; 18 | }; 19 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/api-reponse.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 3 | 4 | import { Response } from 'express'; 5 | import { ErrorResponseType, SuccessResponseType } from '../../types'; 6 | 7 | export class ApiResponse { 8 | static success( 9 | res: Response, 10 | data: SuccessResponseType, 11 | statusCode: number = 200, 12 | ): Response { 13 | return res.status(statusCode).json(data); 14 | } 15 | 16 | static error(res: Response, error: ErrorResponseType): Response { 17 | const { 18 | error: { code, message, suggestions, statusCode }, 19 | } = error; 20 | return res.status(statusCode).json({ 21 | success: false, 22 | error: { status: statusCode, message, suggestions }, 23 | }); 24 | } 25 | } 26 | 27 | export default ApiResponse; 28 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/error/codes.ts: -------------------------------------------------------------------------------- 1 | interface ErrorCode { 2 | code: string; 3 | message: string; 4 | statusCode: number; 5 | } 6 | 7 | interface ErrorCodes { 8 | [key: string]: ErrorCode; 9 | } 10 | 11 | const ErrorCodes: ErrorCodes = { 12 | UNIQUE_FIELD_ERROR: { 13 | code: 'UNIQUE_FIELD_ERROR', 14 | message: 'A field that is supposed to be unique already exists.', 15 | statusCode: 409, 16 | }, 17 | NOT_FOUND_ERROR: { 18 | code: 'NOT_FOUND_ERROR', 19 | message: 'The requested item could not be found.', 20 | statusCode: 404, 21 | }, 22 | DATABASE_ERROR: { 23 | code: 'DATABASE_ERROR', 24 | message: 'There was a problem accessing the database.', 25 | statusCode: 500, 26 | }, 27 | VALIDATION_ERROR: { 28 | code: 'VALIDATION_ERROR', 29 | message: 'Validation failed for one or more fields.', 30 | statusCode: 400, 31 | }, 32 | GENERAL_ERROR: { 33 | code: 'GENERAL_ERROR', 34 | message: 'An unexpected error occurred.', 35 | statusCode: 500, 36 | }, 37 | REQUIRED_FIELD_MISSING: { 38 | code: 'REQUIRED_FIELD_MISSING', 39 | message: 'Required field(s) missing.', 40 | statusCode: 400, 41 | }, 42 | BAD_REQUEST: { 43 | code: 'BAD_REQUEST', 44 | message: 'Required field(s) missing.', 45 | statusCode: 400, 46 | }, 47 | FOUND: { 48 | code: 'FOUND', 49 | message: 'The requested item was found.', 50 | statusCode: 302, 51 | }, 52 | UNAUTHORIZED: { 53 | code: 'UNAUTHORIZED', 54 | message: 'Unauthorized', 55 | statusCode: 401, 56 | }, 57 | FORBIDDEN: { 58 | code: 'FORBIDDEN', 59 | message: 'Forbidden', 60 | statusCode: 403, 61 | }, 62 | INTERNAL_SERVER_ERROR: { 63 | code: 'INTERNAL_SERVER_ERROR', 64 | message: 'Internal Server Error', 65 | statusCode: 500, 66 | }, 67 | MAIL_ERROR: { 68 | code: 'MAIL_ERROR', 69 | message: 'Failed to send email. Please try again later.', 70 | statusCode: 500, 71 | }, 72 | }; 73 | 74 | export default ErrorCodes; 75 | export type { ErrorCode, ErrorCodes }; 76 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/error/global.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import { Request, Response, NextFunction } from 'express'; 4 | import ErrorResponse from './response'; 5 | import ApiResponse from '../api-reponse'; 6 | 7 | const errorHandler = ( 8 | err: any, 9 | req: Request, 10 | res: Response, 11 | next: NextFunction, 12 | ) => { 13 | if (err instanceof ErrorResponse) { 14 | return ApiResponse.error(res, { success: false, error: err }); 15 | } 16 | 17 | const genericError = new ErrorResponse( 18 | 'GENERAL_ERROR', 19 | err.message || 'An unexpected error occurred', 20 | [], 21 | ); 22 | 23 | return ApiResponse.error(res, { 24 | success: false, 25 | error: genericError, 26 | stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, 27 | } as any); 28 | }; 29 | 30 | export default errorHandler; 31 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/error/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GlobalErrorHandler } from './global'; 2 | export * from './codes'; 3 | export { default as NotFoundHandler } from './notfound'; 4 | export { default as ErrorResponse } from './response'; 5 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/error/notfound.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import ErrorResponse from './response'; 3 | 4 | const notFoundHandler = (req: Request, res: Response, next: NextFunction) => { 5 | next(new ErrorResponse('NOT_FOUND_ERROR', 'Resource Not Found')); 6 | }; 7 | 8 | export default notFoundHandler; 9 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/error/response.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-inferrable-types */ 2 | import ErrorCodes, { ErrorCode } from './codes'; 3 | 4 | class ErrorResponse extends Error { 5 | public statusCode: number; 6 | public code: string; 7 | public suggestions: string[]; 8 | public originalError?: Error; 9 | 10 | constructor( 11 | code: string, 12 | message?: string, 13 | suggestions: string[] = [], 14 | originalError?: Error, 15 | ) { 16 | const errorCode: ErrorCode = ErrorCodes[code] || ErrorCodes.GENERAL_ERROR; 17 | super(message || errorCode.message); 18 | this.code = errorCode.code; 19 | this.statusCode = errorCode.statusCode; 20 | this.suggestions = suggestions; 21 | this.originalError = originalError; 22 | 23 | // Capture the stack trace of the original error if provided 24 | if (originalError) { 25 | this.stack = originalError.stack; 26 | } 27 | } 28 | } 29 | 30 | export default ErrorResponse; 31 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-reponse'; 2 | export * from './error'; 3 | -------------------------------------------------------------------------------- /src/common/shared/utils/handlers/res/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fless-lab/node-ts-starter/77cac390035438dd1d9e1545cab534134db2cd4a/src/common/shared/utils/handlers/res/index.ts -------------------------------------------------------------------------------- /src/common/shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handlers'; 2 | -------------------------------------------------------------------------------- /src/common/templates/mail/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Welcome to Our Service 7 | 34 | 35 | 36 |
37 |

Welcome to Our Service, {{firstname}}!

38 |

Your account has been successfully created. Welcome aboard!

39 |

40 | Please verify your email address to complete the registration process. 41 |

42 |

If you have any questions, feel free to contact our support team.

43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /src/core/config/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | interface Config { 6 | runningProd: boolean; 7 | app: string; 8 | port: number; 9 | enableClientAuth: boolean; 10 | basicAuthUser: string; 11 | basicAuthPass: string; 12 | jwt: { 13 | accessTokenSecret: string; 14 | refreshTokenSecret: string; 15 | accessTokenExpireTime: string; 16 | refreshTokenExpireTime: string; 17 | tokenIssuer: string; 18 | }; 19 | rate: { 20 | limit: number; 21 | max: number; 22 | }; 23 | bruteForce: { 24 | freeRetries: number; 25 | minWait: number; 26 | maxWait: number; 27 | lifetime: number; 28 | }; 29 | db: { 30 | uri: string; 31 | name: string; 32 | clientPort: number; 33 | }; 34 | redis: { 35 | host: string; 36 | port: number; 37 | serverPort: number; 38 | tokenExpireTime: number; 39 | blacklistExpireTime: number; 40 | }; 41 | minio: { 42 | endpoint: string; 43 | accessKey: string; 44 | secretKey: string; 45 | apiPort: number; 46 | consolePort: number; 47 | }; 48 | mail: { 49 | host: string; 50 | port: number; 51 | user: string; 52 | pass: string; 53 | from: string; 54 | fromName: string; 55 | }; 56 | bcrypt: { 57 | saltRounds: number; 58 | }; 59 | session: { 60 | secret: string; 61 | }; 62 | viewEngines: string[]; 63 | defaultViewEngine: string; 64 | otp: { 65 | length: number; 66 | expiration: number; 67 | purposes: Record< 68 | string, 69 | { code: string; title: string; description: string; message: string } 70 | >; 71 | }; 72 | } 73 | 74 | export const config: Config = { 75 | runningProd: process.env.NODE_ENV === 'production', 76 | app: process.env.APP_NAME || 'myapp', 77 | port: parseInt(process.env.PORT || '9095', 10), 78 | enableClientAuth: process.env.ENABLE_CLIENT_AUTH === 'true', 79 | basicAuthUser: process.env.BASIC_AUTH_USER || 'admin', 80 | basicAuthPass: process.env.BASIC_AUTH_PASS || 'secret', 81 | jwt: { 82 | accessTokenSecret: process.env.ACCESS_TOKEN_SECRET || '', 83 | refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || '', 84 | accessTokenExpireTime: process.env.ACCESS_TOKEN_EXPIRE_TIME || '1h', 85 | refreshTokenExpireTime: process.env.REFRESH_TOKEN_EXPIRE_TIME || '7d', 86 | tokenIssuer: process.env.TOKEN_ISSUER || 'your-issuer', 87 | }, 88 | rate: { 89 | limit: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes in milliseconds 90 | max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), 91 | }, 92 | bruteForce: { 93 | freeRetries: parseInt(process.env.BRUTE_FORCE_FREE_RETRIES || '5', 10), 94 | minWait: parseInt(process.env.BRUTE_FORCE_MIN_WAIT || '300000', 10), // 5 minutes 95 | maxWait: parseInt(process.env.BRUTE_FORCE_MAX_WAIT || '3600000', 10), // 1 hour 96 | lifetime: parseInt(process.env.BRUTE_FORCE_LIFETIME || '86400', 10), // 1 day in seconds 97 | }, 98 | db: { 99 | uri: process.env.DB_URI || 'mongodb://localhost:27017', 100 | name: process.env.DB_NAME || 'mydatabase', 101 | clientPort: parseInt(process.env.MONGO_CLIENT_PORT || '9005', 10), 102 | }, 103 | redis: { 104 | host: process.env.REDIS_HOST || 'localhost', 105 | port: parseInt(process.env.REDIS_PORT || '6379', 10), 106 | serverPort: parseInt(process.env.REDIS_SERVER_PORT || '9079', 10), 107 | tokenExpireTime: parseInt( 108 | process.env.REDIS_TOKEN_EXPIRE_TIME || '31536000', 109 | 10, 110 | ), 111 | blacklistExpireTime: parseInt( 112 | process.env.REDIS_BLACKLIST_EXPIRE_TIME || '2592000', 113 | 10, 114 | ), 115 | }, 116 | minio: { 117 | endpoint: process.env.MINIO_ENDPOINT || 'localhost', 118 | accessKey: process.env.MINIO_ACCESS_KEY || 'minio-access-key', 119 | secretKey: process.env.MINIO_SECRET_KEY || 'minio-secret-key', 120 | apiPort: parseInt(process.env.MINIO_API_PORT || '9500', 10), 121 | consolePort: parseInt(process.env.MINIO_CONSOLE_PORT || '9050', 10), 122 | }, 123 | mail: { 124 | host: 125 | process.env.NODE_ENV === 'production' 126 | ? process.env.SMTP_HOST || '' 127 | : process.env.MAILDEV_HOST || 'localhost', 128 | port: parseInt( 129 | process.env.NODE_ENV === 'production' 130 | ? process.env.SMTP_PORT || '587' 131 | : process.env.MAILDEV_PORT || '1025', 132 | 10, 133 | ), 134 | user: 135 | process.env.NODE_ENV === 'production' ? process.env.SMTP_USER || '' : '', 136 | pass: 137 | process.env.NODE_ENV === 'production' ? process.env.SMTP_PASS || '' : '', 138 | from: process.env.FROM_EMAIL || 'no-reply@myapp.com', 139 | fromName: process.env.FROM_NAME || 'Your Service Name', 140 | }, 141 | bcrypt: { 142 | saltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10), 143 | }, 144 | session: { 145 | secret: process.env.SESSION_SECRET || 'your-session-secret', 146 | }, 147 | viewEngines: ['ejs', 'pug', 'handlebars', 'nunjucks'], // Supported view engines 148 | defaultViewEngine: process.env.VIEW_ENGINE || 'ejs', 149 | otp: { 150 | length: parseInt(process.env.OTP_LENGTH || '6', 10), 151 | expiration: parseInt(process.env.OTP_EXPIRATION || '5') * 60 * 1000, 152 | purposes: { 153 | ACCOUNT_VERIFICATION: { 154 | code: 'ACCOUNT_VERIFICATION', 155 | title: 'Account Verification OTP', 156 | description: 'Verify your account', 157 | message: 'Your OTP code for account verification is:', 158 | }, 159 | FORGOT_PASSWORD: { 160 | code: 'FORGOT_PASSWORD', 161 | title: 'Password Reset OTP', 162 | description: 'Reset your password', 163 | message: 'Your OTP code for resetting your password is:', 164 | }, 165 | TWO_FACTOR_AUTHENTICATION: { 166 | code: 'TWO_FACTOR_AUTHENTICATION', 167 | title: 'Two-Factor Authentication OTP', 168 | description: 'Two-factor authentication', 169 | message: 'Your OTP code for two-factor authentication is:', 170 | }, 171 | EMAIL_UPDATE: { 172 | code: 'EMAIL_UPDATE', 173 | title: 'Email Update OTP', 174 | description: 'Update your email address', 175 | message: 'Your OTP code for updating your email address is:', 176 | }, 177 | PHONE_VERIFICATION: { 178 | code: 'PHONE_VERIFICATION', 179 | title: 'Phone Verification OTP', 180 | description: 'Verify your phone number', 181 | message: 'Your OTP code for phone verification is:', 182 | }, 183 | TRANSACTION_CONFIRMATION: { 184 | code: 'TRANSACTION_CONFIRMATION', 185 | title: 'Transaction Confirmation OTP', 186 | description: 'Confirm your transaction', 187 | message: 'Your OTP code for transaction confirmation is:', 188 | }, 189 | ACCOUNT_RECOVERY: { 190 | code: 'ACCOUNT_RECOVERY', 191 | title: 'Account Recovery OTP', 192 | description: 'Recover your account', 193 | message: 'Your OTP code for account recovery is:', 194 | }, 195 | CHANGE_SECURITY_SETTINGS: { 196 | code: 'CHANGE_SECURITY_SETTINGS', 197 | title: 'Security Settings Change OTP', 198 | description: 'Change your security settings', 199 | message: 'Your OTP code for changing security settings is:', 200 | }, 201 | LOGIN_CONFIRMATION: { 202 | code: 'LOGIN_CONFIRMATION', 203 | title: 'Login Confirmation OTP', 204 | description: 'Confirm your login', 205 | message: 'Your OTP code for login confirmation is:', 206 | }, 207 | }, 208 | }, 209 | }; 210 | -------------------------------------------------------------------------------- /src/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | import helmet from 'helmet'; 2 | 3 | export const helmetCSPConfig = helmet.contentSecurityPolicy({ 4 | directives: { 5 | defaultSrc: ["'self'"], 6 | scriptSrc: [ 7 | "'self'", 8 | "'unsafe-inline'", 9 | 'https://www.google-analytics.com', 10 | ], 11 | imgSrc: [ 12 | "'self'", 13 | 'data:', 14 | 'https://www.google-analytics.com', 15 | 'https://image.flaticon.com', 16 | 'https://images.unsplash.com', 17 | ], 18 | styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], 19 | fontSrc: ["'self'", 'https://fonts.gstatic.com'], 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/audit-trail.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { AsyncStorageService, logger } from '../../../../../common/shared'; 3 | 4 | const auditTrailPlugin = (schema: Schema) => { 5 | schema.pre('save', function (next) { 6 | const currentUserId = 7 | AsyncStorageService.getInstance().get('currentUserId'); 8 | 9 | if (!currentUserId) { 10 | logger.warn( 11 | 'Warning: currentUserId is undefined. Audit trail fields will not be set.', 12 | ); 13 | } 14 | 15 | if (this.isNew) { 16 | this.set('createdBy', currentUserId || null); 17 | } else { 18 | this.set('updatedBy', currentUserId || null); 19 | } 20 | next(); 21 | }); 22 | 23 | schema.methods.softDelete = function () { 24 | const currentUserId = 25 | AsyncStorageService.getInstance().get('currentUserId'); 26 | this.deletedAt = new Date(); 27 | this.deletedBy = currentUserId || null; 28 | return this.save(); 29 | }; 30 | }; 31 | 32 | export default auditTrailPlugin; 33 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/history.plugin.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema, Document, Types } from 'mongoose'; 2 | import { AsyncStorageService } from '../../../../../common/shared'; 3 | 4 | interface IHistoryDocument extends Document { 5 | originalId: Types.ObjectId; 6 | changes: Record; 7 | snapshot?: any; 8 | modelName: string; 9 | action: 'create' | 'update' | 'softDelete' | 'hardDelete' | 'restore'; 10 | modifiedBy?: Types.ObjectId; 11 | } 12 | 13 | const historySchema = new Schema( 14 | { 15 | originalId: { type: Schema.Types.ObjectId, required: true }, 16 | changes: { type: Object, required: true }, 17 | snapshot: { type: Object }, 18 | modelName: { type: String, required: true }, 19 | action: { 20 | type: String, 21 | enum: ['create', 'update', 'softDelete', 'hardDelete', 'restore'], 22 | required: true, 23 | }, 24 | modifiedBy: { type: Types.ObjectId, ref: 'User' }, 25 | }, 26 | { timestamps: true }, 27 | ); 28 | 29 | const HistoryModel = model('History', historySchema); 30 | 31 | const historyPlugin = ( 32 | schema: Schema, 33 | options: { modelName: string }, 34 | ) => { 35 | const createHistoryEntry = async ( 36 | doc: Document, 37 | action: string, 38 | changes: Record = {}, 39 | snapshot?: any, 40 | ) => { 41 | const currentUserId = 42 | AsyncStorageService.getInstance().get('currentUserId'); 43 | 44 | await new HistoryModel({ 45 | originalId: doc._id, 46 | changes, 47 | snapshot, 48 | modelName: options.modelName, 49 | action, 50 | modifiedBy: currentUserId, 51 | }).save(); 52 | }; 53 | 54 | schema.pre('save', async function (next) { 55 | const action = this.isNew ? 'create' : 'update'; 56 | const changes = this.isNew 57 | ? this.toObject() 58 | : this.modifiedPaths().reduce( 59 | (acc: Record, path: string) => { 60 | acc[path] = this.get(path); 61 | return acc; 62 | }, 63 | {}, 64 | ); 65 | 66 | const snapshot = this.toObject(); 67 | 68 | await createHistoryEntry(this, action, changes, snapshot); 69 | next(); 70 | }); 71 | 72 | schema.methods.softDelete = async function ( 73 | this: T & { deletedAt: Date | null }, 74 | ) { 75 | this.deletedAt = new Date(); 76 | await this.save(); 77 | const snapshot = this.toObject(); 78 | await createHistoryEntry( 79 | this as Document, 80 | 'softDelete', 81 | { deletedAt: this.deletedAt }, 82 | snapshot, 83 | ); 84 | }; 85 | 86 | schema.methods.restore = async function ( 87 | this: T & { deletedAt: Date | null }, 88 | ) { 89 | this.deletedAt = null; 90 | await this.save(); 91 | const snapshot = this.toObject(); 92 | await createHistoryEntry( 93 | this as Document, 94 | 'restore', 95 | { deletedAt: null }, 96 | snapshot, 97 | ); 98 | }; 99 | 100 | schema.pre( 101 | 'deleteOne', 102 | { document: true, query: false }, 103 | async function (next) { 104 | const snapshot = this.toObject(); 105 | await createHistoryEntry(this, 'hardDelete', {}, snapshot); 106 | next(); 107 | }, 108 | ); 109 | 110 | schema.pre('findOneAndDelete', async function (next) { 111 | const doc = await this.model.findOne(this.getQuery()); 112 | if (doc) { 113 | const snapshot = doc.toObject(); 114 | await createHistoryEntry(doc as Document, 'hardDelete', {}, snapshot); 115 | } 116 | next(); 117 | }); 118 | 119 | schema.pre('findOneAndUpdate', async function (next) { 120 | const doc = await this.model.findOne(this.getQuery()); 121 | const changes = this.getUpdate(); 122 | if (doc) { 123 | const snapshot = { ...doc.toObject(), ...changes }; 124 | await createHistoryEntry( 125 | doc as Document, 126 | 'update', 127 | changes as Record, 128 | snapshot, 129 | ); 130 | } 131 | next(); 132 | }); 133 | 134 | schema.pre('deleteMany', async function (next) { 135 | const docs = await this.model.find(this.getQuery()); 136 | for (const doc of docs) { 137 | const snapshot = doc.toObject(); 138 | await createHistoryEntry(doc as Document, 'hardDelete', {}, snapshot); 139 | } 140 | next(); 141 | }); 142 | 143 | schema.pre('updateMany', async function (next) { 144 | const updates = this.getUpdate(); 145 | const docs = await this.model.find(this.getQuery()); 146 | for (const doc of docs) { 147 | const snapshot = { ...doc.toObject(), ...updates }; 148 | await createHistoryEntry( 149 | doc as Document, 150 | 'update', 151 | updates as Record, 152 | snapshot, 153 | ); 154 | } 155 | next(); 156 | }); 157 | }; 158 | 159 | export default historyPlugin; 160 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/index.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | const indexPlugin = ( 3 | schema: Schema, 4 | options: { fields: Record }, 5 | ) => { 6 | schema.index(options.fields); 7 | }; 8 | 9 | export default indexPlugin; 10 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/index.ts: -------------------------------------------------------------------------------- 1 | // src/core/engine/base/_plugins/PluginManager.ts 2 | 3 | import { Schema } from 'mongoose'; 4 | import softDeletePlugin from './soft-delete.plugin'; 5 | import versioningPlugin from './versioning.plugin'; 6 | import auditTrailPlugin from './audit-trail.plugin'; 7 | import historyPlugin from './history.plugin'; 8 | import indexPlugin from './index.plugin'; 9 | 10 | type PluginFunction = (schema: Schema, options?: any) => void; 11 | type PluginWithOptions = [PluginFunction, object?]; 12 | 13 | const PluginManager = { 14 | basePlugins: new Map([ 15 | ['auditTrail', [auditTrailPlugin as PluginFunction]], 16 | ['versioning', [versioningPlugin as PluginFunction]], 17 | ['softDelete', [softDeletePlugin as PluginFunction]], 18 | ['history', [historyPlugin as PluginFunction]], 19 | [ 20 | 'index', 21 | [ 22 | indexPlugin as PluginFunction, 23 | { fields: { createdAt: 1, updatedAt: 1 } }, 24 | ], 25 | ], 26 | ]), 27 | 28 | applyPlugins( 29 | schema: Schema, 30 | options: { 31 | exclude?: string[]; 32 | include?: PluginWithOptions[]; 33 | modelName?: string; 34 | } = {}, 35 | ) { 36 | const { exclude = [], include = [], modelName } = options; 37 | 38 | this.basePlugins.forEach(([plugin, defaultOptions], name) => { 39 | if (!exclude.includes(name)) { 40 | const pluginOptions = { 41 | ...(defaultOptions || {}), 42 | ...(name === 'history' && modelName ? { modelName } : {}), 43 | }; 44 | schema.plugin(plugin, pluginOptions); 45 | } 46 | }); 47 | 48 | include.forEach(([plugin, opts]) => { 49 | const pluginOptions = { ...(opts || {}), modelName }; 50 | schema.plugin(plugin, pluginOptions); 51 | }); 52 | }, 53 | 54 | registerBasePlugin(name: string, plugin: PluginFunction, options?: object) { 55 | this.basePlugins.set(name, [plugin, options]); 56 | }, 57 | }; 58 | 59 | export default PluginManager; 60 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/soft-delete.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | 3 | const softDeletePlugin = (schema: Schema) => { 4 | const deletedAtField = 'deletedAt'; 5 | 6 | schema.add({ [deletedAtField]: { type: Date, default: null } }); 7 | 8 | schema.methods.softDelete = async function () { 9 | this[deletedAtField] = new Date(); 10 | await this.save(); 11 | }; 12 | 13 | schema.methods.restore = async function () { 14 | this[deletedAtField] = null; 15 | await this.save(); 16 | }; 17 | 18 | const addNotDeletedCondition = function (this: any) { 19 | this.where({ [deletedAtField]: null }); 20 | }; 21 | 22 | schema.pre('find', addNotDeletedCondition); 23 | schema.pre('findOne', addNotDeletedCondition); 24 | schema.pre('findOneAndUpdate', addNotDeletedCondition); 25 | schema.pre('updateMany', addNotDeletedCondition); 26 | }; 27 | 28 | export default softDeletePlugin; 29 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/_plugins/versioning.plugin.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document } from 'mongoose'; 2 | 3 | interface IVersionedDocument extends Document { 4 | __version__: number; 5 | } 6 | 7 | const versioningPlugin = (schema: Schema) => { 8 | schema.add({ __version__: { type: Number, default: 0 } }); 9 | 10 | const incrementVersion = (update: any) => { 11 | if (update) { 12 | if (!update.$set) { 13 | update.$set = {}; 14 | } 15 | update.$set.__version__ = (update.$set.__version__ || 0) + 1; 16 | } 17 | }; 18 | 19 | schema.pre('save', function (next) { 20 | if (!this.isNew) { 21 | this.__version__ += 1; 22 | } 23 | next(); 24 | }); 25 | 26 | schema.pre('updateOne', function (next) { 27 | incrementVersion(this.getUpdate()); 28 | next(); 29 | }); 30 | 31 | schema.pre('updateMany', function (next) { 32 | incrementVersion(this.getUpdate()); 33 | next(); 34 | }); 35 | 36 | schema.pre('findOneAndUpdate', function (next) { 37 | incrementVersion(this.getUpdate()); 38 | next(); 39 | }); 40 | }; 41 | 42 | export default versioningPlugin; 43 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/base.model.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, model as mongooseModel, Model } from 'mongoose'; 2 | import PluginManager from './_plugins'; 3 | 4 | interface IBaseModel extends Document { 5 | deletedAt?: Date | null; 6 | deletedBy?: string | null; 7 | createdBy?: string | null; 8 | updatedBy?: string | null; 9 | __version__?: number; 10 | [key: string]: any; 11 | } 12 | 13 | function createBaseSchema( 14 | definition: Record, 15 | options: { 16 | excludePlugins?: string[]; 17 | includePlugins?: [(schema: Schema, options?: any) => void, object?][]; 18 | modelName?: string; 19 | } = {}, 20 | ): Schema { 21 | const baseSchema = new Schema( 22 | { 23 | ...definition, 24 | deletedAt: { type: Date, default: null }, 25 | deletedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, 26 | createdBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, 27 | updatedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, 28 | __version__: { type: Number, default: 0 }, 29 | }, 30 | { timestamps: true }, 31 | ); 32 | 33 | PluginManager.applyPlugins(baseSchema, { 34 | exclude: options.excludePlugins, 35 | include: options.includePlugins, 36 | modelName: options.modelName, 37 | }); 38 | 39 | return baseSchema; 40 | } 41 | 42 | class BaseModel { 43 | private model: Model; 44 | 45 | constructor(modelName: string, schema: Schema) { 46 | this.model = mongooseModel(modelName, schema); 47 | } 48 | 49 | getModel() { 50 | return this.model; 51 | } 52 | } 53 | 54 | export { createBaseSchema, BaseModel, IBaseModel }; 55 | -------------------------------------------------------------------------------- /src/core/engine/base/_models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.model'; 2 | export * from './_plugins'; 3 | -------------------------------------------------------------------------------- /src/core/engine/base/_repositories/base.repo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Model, 3 | Document, 4 | FilterQuery, 5 | UpdateQuery, 6 | QueryOptions, 7 | PipelineStage, 8 | } from 'mongoose'; 9 | 10 | export class BaseRepository { 11 | protected model: Model; 12 | 13 | constructor(model: Model) { 14 | this.model = model; 15 | } 16 | 17 | async create(input: Partial): Promise { 18 | const document = new this.model(input); 19 | return await document.save(); 20 | } 21 | 22 | async findAll( 23 | query: FilterQuery = {}, 24 | options: QueryOptions = {}, 25 | includeDeleted = false, 26 | ): Promise { 27 | const effectiveQuery = includeDeleted 28 | ? query 29 | : { ...query, deletedAt: null }; 30 | return await this.model.find(effectiveQuery, null, options).exec(); 31 | } 32 | 33 | async findOne( 34 | query: FilterQuery, 35 | options: QueryOptions = {}, 36 | includeDeleted = false, 37 | ): Promise { 38 | const effectiveQuery = includeDeleted 39 | ? query 40 | : { ...query, deletedAt: null }; 41 | return await this.model.findOne(effectiveQuery, null, options).exec(); 42 | } 43 | 44 | async update( 45 | query: FilterQuery, 46 | update: UpdateQuery, 47 | options: QueryOptions = {}, 48 | includeDeleted = false, 49 | ): Promise { 50 | const effectiveQuery = includeDeleted 51 | ? query 52 | : { ...query, deletedAt: null }; 53 | return await this.model 54 | .findOneAndUpdate(effectiveQuery, update, { new: true, ...options }) 55 | .exec(); 56 | } 57 | 58 | async delete( 59 | query: FilterQuery, 60 | options: QueryOptions = {}, 61 | softDelete = true, 62 | ): Promise { 63 | if (softDelete) { 64 | return await this.update( 65 | query, 66 | { $set: { deletedAt: new Date() } } as UpdateQuery, 67 | options, 68 | true, 69 | ); 70 | } else { 71 | return await this.model.findOneAndDelete(query, options).exec(); 72 | } 73 | } 74 | 75 | async countDocuments( 76 | query: FilterQuery = {}, 77 | includeDeleted = false, 78 | ): Promise { 79 | const effectiveQuery = includeDeleted 80 | ? query 81 | : { ...query, deletedAt: null }; 82 | return await this.model.countDocuments(effectiveQuery).exec(); 83 | } 84 | 85 | async aggregate(pipeline: PipelineStage[]): Promise { 86 | return await this.model.aggregate(pipeline).exec(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/core/engine/base/_repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.repo'; 2 | -------------------------------------------------------------------------------- /src/core/engine/base/_services/base.service.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { BaseRepository } from '../_repositories'; 3 | import { ErrorResponse } from '../../../../common/shared'; 4 | import { escapeRegex, slugify } from '../../../../helpers'; 5 | import { 6 | ErrorResponseType, 7 | SuccessResponseType, 8 | } from '../../../../common/shared'; 9 | 10 | export class BaseService> { 11 | protected repository: R; 12 | protected handleSlug: boolean; 13 | protected uniqueFields: string[]; 14 | protected populateFields: string[]; 15 | protected allowedFilterFields?: string[]; 16 | protected searchFields?: string[]; 17 | 18 | constructor( 19 | repository: R, 20 | handleSlug = false, 21 | populateFields: string[] = [], 22 | ) { 23 | this.repository = repository; 24 | this.handleSlug = handleSlug; 25 | this.uniqueFields = this.detectUniqueFields(); 26 | this.populateFields = populateFields; 27 | } 28 | 29 | private detectUniqueFields(): string[] { 30 | const uniqueFields: string[] = []; 31 | for (const path in this.repository['model'].schema.paths) { 32 | if (this.repository['model'].schema.paths[path].options?.unique) { 33 | uniqueFields.push(path); 34 | } 35 | } 36 | return uniqueFields; 37 | } 38 | 39 | private filterQueryFields(query: Record): Record { 40 | const filteredQuery: Record = {}; 41 | Object.keys(query) 42 | .filter((key) => this.allowedFilterFields?.includes(key)) 43 | .forEach((key) => { 44 | filteredQuery[key] = query[key]; 45 | }); 46 | return filteredQuery; 47 | } 48 | 49 | private async ensureUniqueField( 50 | doc: Partial, 51 | field: keyof T, 52 | ): Promise { 53 | if (!doc[field]) return; 54 | const exists = await this.repository.findOne({ 55 | [field]: doc[field], 56 | _id: { $ne: doc['_id'] }, 57 | } as any); 58 | if (exists) { 59 | throw new ErrorResponse( 60 | 'UNIQUE_FIELD_ERROR', 61 | `The ${String(field)} must be unique.`, 62 | [`Choose a different ${String(field)}.`], 63 | ); 64 | } 65 | } 66 | 67 | private async ensureUniqueSlug( 68 | doc: Partial, 69 | inputSlugField: keyof T = 'name' as any, 70 | slugField: keyof T = 'slug' as any, 71 | ): Promise { 72 | if (!this.handleSlug || !doc[inputSlugField]) return; 73 | 74 | let slug = slugify(doc[inputSlugField] as unknown as string); 75 | let count = 0; 76 | let exists; 77 | do { 78 | exists = await this.repository.findOne({ 79 | [slugField]: slug, 80 | _id: { $ne: doc['_id'] }, 81 | } as any); 82 | if (exists) 83 | slug = `${slugify(doc[inputSlugField] as unknown as string)}-${++count}`; 84 | } while (exists); 85 | 86 | doc[slugField] = slug as unknown as T[keyof T]; 87 | } 88 | 89 | private async ensureRequiredFields(input: Partial): Promise { 90 | const requiredFields: string[] = []; 91 | for (const path in this.repository['model'].schema.paths) { 92 | if ( 93 | this.repository['model'].schema.paths[path].isRequired && 94 | !Object.prototype.hasOwnProperty.call(input, path) 95 | ) { 96 | requiredFields.push(path); 97 | } 98 | } 99 | if (requiredFields.length > 0) { 100 | throw new ErrorResponse( 101 | 'REQUIRED_FIELD_MISSING', 102 | `Required field(s) missing: ${requiredFields.join(', ')}.`, 103 | [`Please provide all required fields: ${requiredFields.join(', ')}.`], 104 | ); 105 | } 106 | } 107 | 108 | async create( 109 | input: Partial, 110 | ): Promise | ErrorResponseType> { 111 | try { 112 | for (const field of this.uniqueFields) { 113 | await this.ensureUniqueField(input, field as keyof T); 114 | } 115 | if (this.handleSlug) await this.ensureUniqueSlug(input); 116 | await this.ensureRequiredFields(input); 117 | const document = await this.repository.create(input); 118 | return { success: true, document }; 119 | } catch (error) { 120 | return { 121 | success: false, 122 | error: 123 | error instanceof ErrorResponse 124 | ? error 125 | : new ErrorResponse('DATABASE_ERROR', (error as Error).message), 126 | }; 127 | } 128 | } 129 | 130 | async findAll({ 131 | query = {}, 132 | sort = {}, 133 | page = 1, 134 | limit = 10, 135 | searchTerm = '', 136 | paginate = true, 137 | includeDeleted = false, 138 | }: { 139 | query?: Record; 140 | sort?: Record; 141 | page?: number; 142 | limit?: number; 143 | searchTerm?: string; 144 | paginate?: boolean; 145 | includeDeleted?: boolean; 146 | } = {}): Promise | ErrorResponseType> { 147 | try { 148 | let searchQuery = this.filterQueryFields(query); 149 | if (searchTerm && this.searchFields?.length) { 150 | const regex = new RegExp(escapeRegex(searchTerm), 'i'); 151 | const searchConditions = this.searchFields.map((field) => ({ 152 | [field]: regex, 153 | })); 154 | searchQuery = { ...searchQuery, $or: searchConditions }; 155 | } 156 | const documents = await this.repository.findAll( 157 | searchQuery, 158 | { 159 | sort, 160 | skip: (page - 1) * limit, 161 | limit: paginate ? limit : undefined, 162 | }, 163 | includeDeleted, 164 | ); 165 | const total = await this.repository.countDocuments({}, includeDeleted); 166 | const _results = await this.repository.countDocuments( 167 | searchQuery, 168 | includeDeleted, 169 | ); 170 | const results = paginate ? documents.length : total; 171 | return { 172 | success: true, 173 | total, 174 | _results, 175 | results, 176 | documents, 177 | page: paginate ? page : undefined, 178 | limit: paginate ? limit : undefined, 179 | }; 180 | } catch (error) { 181 | return { 182 | success: false, 183 | total: 0, 184 | _results: 0, 185 | results: 0, 186 | documents: [], 187 | error: 188 | error instanceof ErrorResponse 189 | ? error 190 | : new ErrorResponse('DATABASE_ERROR', (error as Error).message), 191 | }; 192 | } 193 | } 194 | 195 | async findOne( 196 | query: Record, 197 | includeDeleted = false, 198 | ): Promise | ErrorResponseType> { 199 | try { 200 | const document = await this.repository.findOne(query, {}, includeDeleted); 201 | if (!document) { 202 | throw new ErrorResponse( 203 | 'NOT_FOUND_ERROR', 204 | 'The requested document was not found.', 205 | ); 206 | } 207 | return { success: true, document }; 208 | } catch (error) { 209 | return { 210 | success: false, 211 | error: 212 | error instanceof ErrorResponse 213 | ? error 214 | : new ErrorResponse('DATABASE_ERROR', (error as Error).message), 215 | }; 216 | } 217 | } 218 | 219 | async update( 220 | query: Record, 221 | updateInput: Partial, 222 | includeDeleted = false, 223 | ): Promise | ErrorResponseType> { 224 | try { 225 | const documentToUpdate = await this.repository.findOne( 226 | query, 227 | {}, 228 | includeDeleted, 229 | ); 230 | if (!documentToUpdate) { 231 | throw new ErrorResponse( 232 | 'NOT_FOUND_ERROR', 233 | 'Document to update not found.', 234 | ); 235 | } 236 | const fieldsToUpdate: Partial = {}; 237 | for (const key in updateInput) { 238 | if (updateInput[key] !== documentToUpdate[key]) { 239 | fieldsToUpdate[key as keyof T] = updateInput[key]; 240 | } 241 | } 242 | for (const field of this.uniqueFields) { 243 | if ( 244 | fieldsToUpdate[field as keyof T] && 245 | fieldsToUpdate[field as keyof T] !== 246 | documentToUpdate[field as keyof T] 247 | ) { 248 | await this.ensureUniqueField(fieldsToUpdate, field as keyof T); 249 | } 250 | } 251 | const slugUpdateContext = { 252 | ...fieldsToUpdate, 253 | _id: documentToUpdate._id, 254 | }; 255 | if ( 256 | this.handleSlug && 257 | (fieldsToUpdate as any).name && 258 | (documentToUpdate as any).name !== (fieldsToUpdate as any).name 259 | ) { 260 | await this.ensureUniqueSlug( 261 | slugUpdateContext, 262 | 'name' as any, 263 | 'slug' as any, 264 | ); 265 | (fieldsToUpdate as any).slug = (slugUpdateContext as any) 266 | .slug as unknown as T[keyof T]; 267 | } 268 | const updatedDocument = await this.repository.update( 269 | query, 270 | fieldsToUpdate, 271 | {}, 272 | includeDeleted, 273 | ); 274 | if (!updatedDocument) { 275 | throw new ErrorResponse( 276 | 'NOT_FOUND_ERROR', 277 | 'Updated document not found.', 278 | ); 279 | } 280 | return { success: true, document: updatedDocument }; 281 | } catch (error) { 282 | return { 283 | success: false, 284 | error: 285 | error instanceof ErrorResponse 286 | ? error 287 | : new ErrorResponse('DATABASE_ERROR', (error as Error).message), 288 | }; 289 | } 290 | } 291 | 292 | async delete( 293 | query: Record, 294 | softDelete = true, 295 | ): Promise | ErrorResponseType> { 296 | try { 297 | const deletedDocument = await this.repository.delete( 298 | query, 299 | {}, 300 | softDelete, 301 | ); 302 | if (!deletedDocument) { 303 | throw new ErrorResponse( 304 | 'NOT_FOUND_ERROR', 305 | softDelete 306 | ? 'Document to soft delete not found.' 307 | : 'Document to delete not found.', 308 | ); 309 | } 310 | return { success: true, document: deletedDocument }; 311 | } catch (error) { 312 | return { 313 | success: false, 314 | error: 315 | error instanceof ErrorResponse 316 | ? error 317 | : new ErrorResponse('DATABASE_ERROR', (error as Error).message), 318 | }; 319 | } 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/core/engine/base/_services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.service'; 2 | -------------------------------------------------------------------------------- /src/core/engine/base/index.ts: -------------------------------------------------------------------------------- 1 | export * from './_repositories'; 2 | export * from './_services'; 3 | export * from './_models'; 4 | -------------------------------------------------------------------------------- /src/core/engine/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | -------------------------------------------------------------------------------- /src/core/framework/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mongoose'; 2 | export * from './redis'; 3 | -------------------------------------------------------------------------------- /src/core/framework/database/mongoose/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Connection } from 'mongoose'; 2 | import { config } from '../../../config'; 3 | 4 | let mongoClient: Connection | null = null; 5 | 6 | async function connect(uri: string, dbName: string): Promise { 7 | return new Promise((resolve, reject) => { 8 | mongoose 9 | .connect(uri, { dbName }) 10 | .then(() => { 11 | mongoClient = mongoose.connection; 12 | console.info('Mongoose connected to db'); 13 | resolve(); 14 | }) 15 | .catch((err: mongoose.Error) => { 16 | console.error('Mongoose connection error:', err); 17 | reject(err); 18 | }); 19 | }); 20 | } 21 | 22 | async function init( 23 | uri: string = config.db.uri, 24 | dbName: string = config.db.name, 25 | ): Promise { 26 | try { 27 | await connect(uri, dbName); 28 | console.info('Mongodb initialised.'); 29 | } catch (err: unknown) { 30 | if (err instanceof mongoose.Error) { 31 | console.error('Connection error:', err); 32 | } else { 33 | console.error('Unexpected error:', err); 34 | } 35 | throw err; 36 | } 37 | } 38 | 39 | async function getClient(): Promise { 40 | if (!mongoClient) { 41 | const error = new Error('Connection not initialized. Call init() first.'); 42 | console.error(error); 43 | throw error; 44 | } 45 | 46 | return mongoClient; 47 | } 48 | 49 | async function close(): Promise { 50 | if (mongoClient) { 51 | await mongoose.disconnect(); 52 | console.warn('Mongoose connection is disconnected.'); 53 | } else { 54 | console.warn('No mongoose connection found to close.'); 55 | } 56 | } 57 | 58 | export { init, getClient, close }; 59 | -------------------------------------------------------------------------------- /src/core/framework/database/mongoose/index.ts: -------------------------------------------------------------------------------- 1 | export * as mongo from './db'; 2 | -------------------------------------------------------------------------------- /src/core/framework/database/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * as redis from './redis'; 2 | -------------------------------------------------------------------------------- /src/core/framework/database/redis/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import { config } from '../../../config'; 3 | 4 | let redisClient: Redis | null = null; 5 | 6 | function init(): void { 7 | redisClient = new Redis({ 8 | port: config.redis.port, // Redis port from config 9 | host: config.redis.host, // Redis host from config 10 | }); 11 | 12 | redisClient.on('connect', () => { 13 | console.info('Client connected to Redis...'); 14 | }); 15 | 16 | redisClient.on('ready', () => { 17 | console.info('Client connected to Redis and ready to use...'); 18 | }); 19 | 20 | redisClient.on('error', (err) => { 21 | console.error(err.message); 22 | }); 23 | 24 | redisClient.on('end', () => { 25 | console.warn('Client disconnected from Redis'); 26 | }); 27 | 28 | process.on('SIGINT', () => { 29 | console.log('On client quit'); 30 | if (redisClient) { 31 | redisClient.quit(); 32 | } 33 | }); 34 | } 35 | 36 | function getClient(): Redis { 37 | if (!redisClient) { 38 | throw new Error('Redis client not initialized. Call init() first.'); 39 | } 40 | return redisClient; 41 | } 42 | 43 | async function close(): Promise { 44 | if (redisClient) { 45 | await redisClient.quit(); 46 | console.warn('Redis connection is disconnected.'); 47 | } else { 48 | console.warn('No Redis connection found to close.'); 49 | } 50 | } 51 | 52 | export { init, getClient, close }; 53 | -------------------------------------------------------------------------------- /src/core/framework/index.ts: -------------------------------------------------------------------------------- 1 | export * as DB from './database'; 2 | export * as S3 from './storage'; 3 | export * from './session-flash'; 4 | export * from './view-engine'; 5 | export * as WebServer from './webserver'; 6 | -------------------------------------------------------------------------------- /src/core/framework/session-flash/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express'; 2 | import session from 'express-session'; 3 | import flash from 'connect-flash'; 4 | import { config } from '../../config'; 5 | 6 | export const initializeSessionAndFlash = (app: Application): void => { 7 | app.use( 8 | session({ 9 | secret: config.session.secret, 10 | resave: false, 11 | saveUninitialized: true, 12 | cookie: { secure: config.runningProd }, 13 | }), 14 | ); 15 | app.use(flash()); 16 | }; 17 | -------------------------------------------------------------------------------- /src/core/framework/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './minio'; 2 | -------------------------------------------------------------------------------- /src/core/framework/storage/minio/index.ts: -------------------------------------------------------------------------------- 1 | export * as minio from './minio'; 2 | -------------------------------------------------------------------------------- /src/core/framework/storage/minio/minio.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'minio'; 2 | import { config } from '../../../config'; 3 | 4 | let minioClient: Client | null = null; 5 | 6 | function connect( 7 | endpoint: string, 8 | accessKey: string, 9 | secretKey: string, 10 | ): Client { 11 | minioClient = new Client({ 12 | endPoint: endpoint, 13 | port: 9000, 14 | useSSL: false, 15 | accessKey, 16 | secretKey, 17 | }); 18 | 19 | console.info('MinIO connected successfully'); 20 | return minioClient; 21 | } 22 | 23 | function init(): Client { 24 | if (!minioClient) { 25 | minioClient = connect( 26 | config.minio.endpoint, 27 | config.minio.accessKey, 28 | config.minio.secretKey, 29 | ); 30 | } 31 | return minioClient; 32 | } 33 | 34 | function getClient(): Client { 35 | if (!minioClient) { 36 | const error = new Error('Connection not initialized. Call init() first.'); 37 | console.error(error); 38 | throw error; 39 | } 40 | 41 | return minioClient; 42 | } 43 | 44 | export { init, getClient }; 45 | -------------------------------------------------------------------------------- /src/core/framework/view-engine/ejs.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | 4 | export default (app: express.Application): void => { 5 | app.set('view engine', 'ejs'); 6 | app.set('views', path.join(__dirname, '../../../../views')); 7 | app.use(express.static(path.join(__dirname, '../../../../public'))); 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/framework/view-engine/handlebars.ts: -------------------------------------------------------------------------------- 1 | // TODO: add handlebars init 2 | -------------------------------------------------------------------------------- /src/core/framework/view-engine/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express'; 2 | import { config } from '../../config'; 3 | import { logger } from '../../../common/shared'; 4 | 5 | const initializeViewEngine = async (app: Application): Promise => { 6 | const viewEngine = config.defaultViewEngine; 7 | 8 | if (!config.viewEngines.includes(viewEngine)) { 9 | throw new Error( 10 | `View engine ${viewEngine} is not supported. Please choose one of the following: ${config.viewEngines.join(', ')}.`, 11 | ); 12 | } 13 | 14 | try { 15 | const viewEngineModule = await import(`./${viewEngine}`); 16 | viewEngineModule.default(app); 17 | logger.info(`${viewEngine} view engine initialized.`); 18 | } catch (error) { 19 | logger.error( 20 | `Failed to initialize ${viewEngine} view engine.`, 21 | error as Error, 22 | ); 23 | throw new Error(`View engine ${viewEngine} not supported.`); 24 | } 25 | }; 26 | 27 | export default initializeViewEngine; 28 | -------------------------------------------------------------------------------- /src/core/framework/view-engine/nunjucks.ts: -------------------------------------------------------------------------------- 1 | // TODO: add nunjuck init 2 | -------------------------------------------------------------------------------- /src/core/framework/view-engine/pug.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | 4 | export default (app: express.Application): void => { 5 | app.set('view engine', 'pug'); 6 | app.set('views', path.join(__dirname, '../../../../views')); 7 | app.use(express.static(path.join(__dirname, '../../../../views'))); 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/framework/webserver/express.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import express from 'express'; 3 | import cors from 'cors'; 4 | import helmet from 'helmet'; 5 | import morgan from 'morgan'; 6 | import initializeViewEngine from '../view-engine'; 7 | import { initializeSessionAndFlash } from '../session-flash'; 8 | import { 9 | apiRateLimiter, 10 | clientAuthentication, 11 | GlobalErrorHandler, 12 | NotFoundHandler, 13 | } from '../../../common/shared'; 14 | import { default as AllRoutes } from '../../../common/global-router'; 15 | import { config } from '../../config'; 16 | import { helmetCSPConfig } from '../../constants'; 17 | 18 | const app = express(); 19 | const morganEnv = config.runningProd ? 'combined' : 'dev'; 20 | 21 | // Express configuration 22 | app.use(cors()); 23 | app.use(helmet()); // Use Helmet to add various security headers 24 | app.use(helmetCSPConfig); 25 | app.use(helmet.frameguard({ action: 'deny' })); // Prevent the app from being displayed in an iframe 26 | app.use(helmet.xssFilter()); // Protect against XSS attacks 27 | app.use(helmet.noSniff()); // Prevent MIME type sniffing 28 | app.use(helmet.ieNoOpen()); // Prevent IE from executing downloads 29 | app.use(morgan(morganEnv)); 30 | app.use(express.json()); 31 | app.disable('x-powered-by'); // Disable X-Powered-By header 32 | 33 | // Initialize Session and Flash 34 | initializeSessionAndFlash(app); 35 | 36 | // Set view engine 37 | initializeViewEngine(app); 38 | 39 | // Client authentication middleware 40 | app.use(clientAuthentication); 41 | 42 | // Client authentication middleware 43 | app.use(apiRateLimiter); 44 | 45 | // API Routes 46 | app.use('/api/v1', AllRoutes); 47 | 48 | // Error handlers 49 | app.use(NotFoundHandler); 50 | app.use(GlobalErrorHandler); 51 | 52 | export default app; 53 | -------------------------------------------------------------------------------- /src/core/framework/webserver/index.ts: -------------------------------------------------------------------------------- 1 | export { default as app } from './express'; 2 | -------------------------------------------------------------------------------- /src/helpers/db-connection-test.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../core/config'; 2 | import { DB } from '../core/framework'; 3 | 4 | export async function testDatabaseConnection() { 5 | try { 6 | await DB.mongo.init(config.db.uri, config.db.name); 7 | console.info('Mongodb initialised.'); 8 | } catch (error) { 9 | console.error('Failed to initialize MongoDB:', error); 10 | throw error; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/generator.ts: -------------------------------------------------------------------------------- 1 | export const generateRandomOTP = (length: number) => { 2 | const digits = '0123456789'; 3 | let OTP = ''; 4 | for (let i = 0; i < length; i++) { 5 | OTP += digits[Math.floor(Math.random() * 10)]; 6 | } 7 | return OTP; 8 | }; 9 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './init-services'; 2 | export * from './db-connection-test'; 3 | export * from './generator'; 4 | export * from './list-routes'; 5 | export * from './minio-test'; 6 | export * from './time'; 7 | export * from './string'; 8 | export * from './redis-test'; 9 | -------------------------------------------------------------------------------- /src/helpers/init-services.ts: -------------------------------------------------------------------------------- 1 | import { testDatabaseConnection } from './db-connection-test'; 2 | import { testRedisConnection } from './redis-test'; 3 | import { testMinioConnection } from './minio-test'; 4 | 5 | async function initServices(): Promise { 6 | await testDatabaseConnection(); 7 | await testRedisConnection(); 8 | await testMinioConnection(); 9 | } 10 | 11 | export { initServices }; 12 | -------------------------------------------------------------------------------- /src/helpers/list-routes.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'express'; 2 | import expressListEndpoints from 'express-list-endpoints'; 3 | 4 | function listRoutes(app: Application) { 5 | const routes = expressListEndpoints(app); 6 | return routes.map((route) => ({ 7 | path: route.path, 8 | methods: route.methods, 9 | middlewares: route.middlewares, 10 | })); 11 | } 12 | 13 | export { listRoutes }; 14 | -------------------------------------------------------------------------------- /src/helpers/minio-test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../common/shared'; 2 | import { S3 } from '../core/framework'; 3 | 4 | async function testMinioConnection(): Promise { 5 | try { 6 | const client = S3.minio.init(); 7 | // Example of checking MinIO server status by listing buckets 8 | await client.listBuckets(); 9 | logger.info('MinIO is successfully connected and working.'); 10 | } catch (error) { 11 | logger.error('MinIO connection error:', error as Error); 12 | throw error; 13 | } 14 | } 15 | 16 | export { testMinioConnection }; 17 | -------------------------------------------------------------------------------- /src/helpers/redis-test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../common/shared'; 2 | import { DB } from '../core/framework'; 3 | 4 | async function testRedisConnection(): Promise { 5 | try { 6 | const redis = DB.redis; 7 | redis.init(); 8 | const client = redis.getClient(); 9 | await client.ping(); 10 | logger.info('Redis is successfully connected and working.'); 11 | } catch (error) { 12 | logger.error('Redis connection error:', error as Error); 13 | throw error; 14 | } 15 | } 16 | 17 | export { testRedisConnection }; 18 | -------------------------------------------------------------------------------- /src/helpers/string.ts: -------------------------------------------------------------------------------- 1 | export const slugify = (text: string): string => { 2 | return text 3 | .toString() 4 | .toLowerCase() 5 | .trim() 6 | .replace(/\s+/g, '-') 7 | .replace(/[^\w-]+/g, '') 8 | .replace(/--+/g, '-') 9 | .replace(/^-+/, '') 10 | .replace(/-+$/, ''); 11 | }; 12 | 13 | export const escapeRegex = (text: string): string => { 14 | return text.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); 15 | }; 16 | -------------------------------------------------------------------------------- /src/helpers/time.ts: -------------------------------------------------------------------------------- 1 | export const msToMinutes = (ms: number): number => { 2 | return Math.ceil(ms / 60000); 3 | }; 4 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | process.on('uncaughtException', function (err) { 2 | console.error('Uncaught Exception:', err); 3 | }); 4 | 5 | import { initServices } from './helpers'; 6 | import { WebServer } from './core/framework'; 7 | import { logger } from './common/shared'; 8 | import { config } from './core/config'; 9 | 10 | async function startServer() { 11 | try { 12 | await initServices(); 13 | const app = WebServer.app; 14 | app.listen(config.port, () => { 15 | logger.info(`Server running on http://localhost:${config.port}`); 16 | }); 17 | } catch (error) { 18 | logger.error('Failed to initialize services', error as any); 19 | } 20 | } 21 | 22 | startServer(); 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | 10 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | Use alias instead of simple paths -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "./build", 10 | "rootDir": "./src", 11 | "typeRoots": [ 12 | "./node_modules/@types", 13 | "./src/types" 14 | ] 15 | }, 16 | "include": ["src/**/*.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | 20 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Accueil 7 | 65 | 66 | 67 |
68 |

Bienvenue sur notre plateforme

69 | 70 | <% if (successMessages && successMessages.length > 0) { %> 71 |
72 | <% successMessages.forEach(function(message) { %> 73 |

<%= message %>

74 | <% }); %> 75 |
76 | <% } %> 77 | 78 | <% if (errorMessages && errorMessages.length > 0) { %> 79 |
80 | <% errorMessages.forEach(function(message) { %> 81 |

<%= message %>

82 | <% }); %> 83 |
84 | <% } %> 85 | 86 |

Ceci est la page d'accueil de notre plateforme.

87 | 88 |
89 |

Routes disponibles :

90 | <% routes.forEach(function(route) { %> 91 |
92 | <%= route.methods.join(', ') %> 93 | <%= route.path %> 94 | [<%= route.middlewares.join(', ') %>] 95 |
96 | <% }); %> 97 |
98 |
99 | 100 | 101 | --------------------------------------------------------------------------------