├── .env.template ├── .github └── workflows │ └── docker-images-deploy.yml ├── .gitignore ├── README.md ├── backend ├── .cursorrules ├── .dockerignore ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── eslint.config.mjs ├── jest.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── prisma │ ├── migrations │ │ ├── 20250519092406_initial │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── scripts │ └── build.mjs ├── src │ ├── app.ts │ ├── cli │ │ ├── cli.ts │ │ └── commands │ │ │ ├── create-admin-account.command.ts │ │ │ ├── redis-clear.command.ts │ │ │ ├── seed-countries.command.ts │ │ │ └── test.command.ts │ ├── common │ │ ├── cron │ │ │ ├── index.ts │ │ │ └── media-delete-orphans.cron.ts │ │ ├── database │ │ │ └── prisma.ts │ │ ├── events │ │ │ └── events.service.ts │ │ ├── exceptions │ │ │ ├── http-exception.ts │ │ │ └── validation-exception.ts │ │ ├── guards │ │ │ ├── abilities.guard.ts │ │ │ ├── roles.guard.ts │ │ │ └── sessions.guard.ts │ │ ├── locales │ │ │ └── zod │ │ │ │ └── fr.json │ │ ├── middlewares │ │ │ ├── exceptions.middleware.ts │ │ │ ├── rewrite-ip-address.middleware.ts │ │ │ ├── trim.middleware.ts │ │ │ └── unknown-routes.middleware.ts │ │ ├── queue │ │ │ ├── app.worker.ts │ │ │ ├── bullmq.ts │ │ │ ├── jobs │ │ │ │ ├── optimize-video.job.ts │ │ │ │ └── testing.job.ts │ │ │ └── queue.service.ts │ │ ├── services │ │ │ ├── app.service.ts │ │ │ ├── auth.service.ts │ │ │ ├── files.service.ts │ │ │ ├── pdf.service.ts │ │ │ └── redis.service.ts │ │ ├── storage │ │ │ └── s3.ts │ │ ├── test │ │ │ ├── jest-initialize-db.ts │ │ │ ├── jest-setup.ts │ │ │ └── jest-utils.ts │ │ ├── throttlers │ │ │ ├── global.throttler.ts │ │ │ ├── strict.throttler.ts │ │ │ └── usage.throttler.ts │ │ ├── transformers │ │ │ ├── empty-string-to-null.transformer.ts │ │ │ └── phone-number.transformer.ts │ │ ├── types │ │ │ └── request.d.ts │ │ ├── utils │ │ │ ├── app-dir.ts │ │ │ ├── date.ts │ │ │ ├── ffmpeg.ts │ │ │ ├── logger.ts │ │ │ ├── page-query.ts │ │ │ ├── phone-utils.ts │ │ │ ├── swagger.ts │ │ │ ├── validation.ts │ │ │ ├── wolfios.ts │ │ │ └── zod.ts │ │ ├── validators │ │ │ ├── bic.validator.ts │ │ │ ├── iban.validator.ts │ │ │ └── phone-number.validator.ts │ │ └── views │ │ │ └── invoice.ejs │ ├── config.ts │ ├── core │ │ └── use-cases │ │ │ └── test-write-text.usecase.ts │ ├── instrument.ts │ ├── modules │ │ ├── auth │ │ │ ├── auth.config.ts │ │ │ ├── controllers │ │ │ │ └── auth.controller.ts │ │ │ ├── routes │ │ │ │ └── auth.routes.ts │ │ │ ├── strategies │ │ │ │ └── jwt.strategy.ts │ │ │ ├── tests │ │ │ │ └── auth.controller.test.ts │ │ │ └── validators │ │ │ │ └── auth.validator.ts │ │ ├── me │ │ │ ├── controllers │ │ │ │ └── me.controller.ts │ │ │ └── routes │ │ │ │ └── me.routes.ts │ │ ├── media │ │ │ ├── controllers │ │ │ │ └── media.controller.ts │ │ │ ├── media.service.ts │ │ │ └── routes │ │ │ │ └── media.routes.ts │ │ ├── test-author │ │ │ └── test-author.service.ts │ │ └── test │ │ │ ├── controllers │ │ │ └── test.controller.ts │ │ │ ├── dtos │ │ │ └── account.dto.ts │ │ │ ├── routes │ │ │ └── test.routes.ts │ │ │ ├── test.config.ts │ │ │ ├── test.listener.ts │ │ │ ├── test.service.ts │ │ │ ├── tests │ │ │ └── test.controller.test.ts │ │ │ └── validators │ │ │ └── test.validators.ts │ ├── routes.ts │ ├── server.ts │ └── static │ │ └── assets │ │ └── css │ │ └── input.css ├── tailwind.config.js └── tsconfig.json ├── docker-compose.dev.yml ├── docker-compose.yml ├── frontend ├── .cursorrules ├── .gitignore ├── Dockerfile ├── README.md ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg ├── src │ └── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx ├── tailwind.config.ts └── tsconfig.json ├── jakefile.js └── nginx ├── .gitignore ├── Dockerfile ├── conf ├── common.conf ├── default.conf └── servers.conf └── nginx.conf /.env.template: -------------------------------------------------------------------------------- 1 | PROJECT_NAME="ultimate-typescript-starter-kit" 2 | X11_DISPLAY="host.docker.internal:0" 3 | 4 | 5 | # ----------------- 6 | # Backend 7 | # ----------------- 8 | APP_PORT=3000 9 | APP_BASE_URL=http://localhost 10 | APP_DATABASE_CONNECTION_URL="postgresql://ghostlexly:password@postgres:5432/ultimate-typescript-starter-kit?schema=public" 11 | APP_REDIS_HOST="redis" 12 | APP_REDIS_PORT="6379" 13 | APP_JWT_SECRET="YOUR_SECRET_JWT_KEY" 14 | API_S3_ENDPOINT= 15 | API_S3_ACCESS_KEY= 16 | API_S3_SECRET_KEY= 17 | API_S3_BUCKET= 18 | API_GOOGLE_CLIENT_ID= 19 | API_GOOGLE_CLIENT_SECRET= 20 | APP_PLAYWRIGHT_HEADLESS="true" 21 | API_SENTRY_AUTH_TOKEN= 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/docker-images-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Docker Images Deploy 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-24.04 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Create environment file(s) 16 | run: | 17 | echo "${{ secrets.ENV_FILE }}" > .env 18 | 19 | - name: Login to docker registry 20 | uses: docker/login-action@v2 21 | with: 22 | username: fenrisshq 23 | password: ${{ secrets.DOCKER_PASSWORD }} 24 | 25 | - name: Build and push docker images 26 | run: | 27 | NODE_ENV=production docker compose build 28 | docker compose push 29 | 30 | - name: Transfer files to production server 31 | uses: appleboy/scp-action@master 32 | with: 33 | host: 15.237.74.17 34 | username: ubuntu 35 | key: ${{ secrets.SSH_PRIVATE_KEY }} 36 | source: "docker-compose.yml,.env" 37 | target: "~/docker" 38 | 39 | - name: Deploy on production server 40 | uses: appleboy/ssh-action@master 41 | with: 42 | host: 15.237.74.17 43 | username: ubuntu 44 | key: ${{ secrets.SSH_PRIVATE_KEY }} 45 | script: | 46 | # Go to docker directory 47 | cd ~/docker 48 | 49 | # Login to docker registry 50 | docker login -u fenrisshq -p ${{ secrets.DOCKER_PASSWORD }} 51 | 52 | # Pull new images 53 | docker compose pull 54 | 55 | # Stop and remove existing containers 56 | docker compose down 57 | 58 | # Start new containers 59 | docker compose up -d 60 | 61 | - name: Run migrations on production server 62 | uses: appleboy/ssh-action@master 63 | with: 64 | host: 15.237.74.17 65 | username: ubuntu 66 | key: ${{ secrets.SSH_PRIVATE_KEY }} 67 | script: | 68 | # Go to docker directory 69 | cd ~/docker 70 | 71 | # Run migrations 72 | docker compose exec backend npx prisma migrate deploy 73 | 74 | - name: Run sentry sourcemaps on production server 75 | uses: appleboy/ssh-action@master 76 | with: 77 | host: 15.237.74.17 78 | username: ubuntu 79 | key: ${{ secrets.SSH_PRIVATE_KEY }} 80 | script: | 81 | # Go to backend directory 82 | cd ~/docker 83 | 84 | # Run sentry sourcemaps 85 | docker compose exec backend npm run sentry:sourcemaps 86 | 87 | - name: Prune untagged docker images 88 | uses: appleboy/ssh-action@master 89 | with: 90 | host: 15.237.74.17 91 | username: ubuntu 92 | key: ${{ secrets.SSH_PRIVATE_KEY }} 93 | script: | 94 | # Go to docker directory 95 | cd ~/docker 96 | 97 | # Prune untagged images 98 | docker image prune -f 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.Dockerfile 2 | *.docker 3 | phpfpm_docker/project-files/ 4 | nginx_docker/project-files/ 5 | .idea/ 6 | .php_cs.cache 7 | logs/ 8 | saves/ 9 | export.tar 10 | mutagen.yml 11 | mutagen.yml.lock 12 | certbot/conf/.certbot.lock 13 | mysql 14 | postgres 15 | 16 | phpfpm/.DS_Store 17 | phpfpm/project-files/.DS_Store 18 | .DS_Store 19 | 20 | .env 21 | .env.local 22 | .env.prod 23 | Taskfile.yaml 24 | node_modules 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Ultimate TypeScript Full Stack Starter 2 | 3 | ## 🌟 Overview 4 | 5 | Welcome to the Ultimate TypeScript Full Stack Starter! This comprehensive toolkit provides a robust foundation for building scalable, feature-rich applications using the MERN (MySQL, Express, React, Node.js) stack with TypeScript. 6 | 7 | ### 🎯 Why Choose This Starter? 8 | 9 | - **Full Stack**: Covers both backend (Express.js) and frontend (Next.js) development 10 | - **TypeScript**: Enjoy the benefits of static typing and improved developer experience 11 | - **Batteries Included**: Packed with essential features and best practices 12 | - **Scalable**: Designed to grow with your project needs 13 | - **Modern**: Utilizes the latest technologies and patterns 14 | 15 | ## 🛠 Tech Stack 16 | 17 | - **Database**: MySQL with Prisma ORM 18 | - **Backend**: Express.js 19 | - **Frontend**: Next.js (Server-side rendering, Static site generation, Client-side rendering) 20 | - **Language**: TypeScript 21 | - **Containerization**: Docker 22 | 23 | ## 🔑 Key Features 24 | 25 | ### 🖥 Backend 26 | 27 | - **Authentication**: Secure bearer sessions and OAuth (Google, Facebook, Twitter, GitHub) via Passport.js 28 | - **Authorization**: Fine-grained access control with CASL 29 | - **Validation**: Request validation using Zod 30 | - **Error Handling**: Comprehensive error management system 31 | - **Logging**: Advanced logging with Pino and log rotation 32 | - **File Management**: S3 integration for file and video uploads 33 | - **Video Processing**: Conversion to browser-compatible formats with FFmpeg 34 | - **Caching**: Redis integration for improved performance 35 | - **Rate Limiting**: API protection with express-rate-limit 36 | - **Background Processing**: Efficient task handling with BullMQ and node-cron 37 | - **Internationalization**: Multi-language support with i18n 38 | - **API Documentation**: Swagger integration 39 | - **Events**: Powerful event handling with EventEmitter2 40 | 41 | ### 🎨 Frontend 42 | 43 | - **UI Framework**: React with Next.js 44 | - **Styling**: Tailwind CSS 45 | - **State Management**: zustand 46 | - **Form Handling**: React Hook Form 47 | 48 | ### 🔒 Security 49 | 50 | - CORS protection 51 | - Secure authentication 52 | - Request validation 53 | - Rate limiting 54 | 55 | ### ⚡ Performance 56 | 57 | - `esbuild` bundling for faster builds 58 | - Hot-reload for rapid development 59 | - Redis caching 60 | - Optimized architecture 61 | 62 | ### 💻 Developer Experience 63 | 64 | - Docker support for easy setup and deployment 65 | - Code formatting with `Prettier` 66 | - Linting with `ESLint` 67 | 68 | ## 🚀 Getting Started 69 | 70 | 1. **Clone the repository** 71 | 72 | ``` 73 | git clone https://github.com/ghostlexly/ultimate-typescript-starter-kit.git 74 | cd ultimate-typescript-starter-kit 75 | ``` 76 | 77 | 2. **Set up environment variables** 78 | Copy `.env.template` to `.env` and fill in your values 79 | 80 | 3. **Install dependencies** 81 | 82 | ``` 83 | cd backend 84 | npm install 85 | 86 | cd ../frontend 87 | npm install 88 | ``` 89 | 90 | 4. **Start the development environment** 91 | 92 | ``` 93 | docker compose -f docker-compose.yml -f docker-compose.dev.yml up 94 | ``` 95 | 96 | 5. **Access the application** 97 | - Frontend: http://localhost 98 | - Backend: http://localhost/api/ 99 | - Swagger Docs: http://localhost/api/swagger 100 | 101 | ## 📚 Documentation 102 | 103 | For detailed documentation on each feature and how to use this starter kit, please refer to our [Wiki](https://github.com/ghostlexly/ultimate-typescript-starter-kit/wiki). 104 | 105 | ## 🤝 Contributing 106 | 107 | We welcome contributions! 108 | 109 | ## 📄 License 110 | 111 | This project is licensed under the [MIT License](LICENSE). 112 | 113 | ## 🙏 Acknowledgements 114 | 115 | - See backend [README](./backend/README.md) for more details. 116 | - See frontend [README](./frontend/README.md) for more details. 117 | 118 | ## 📞 Support 119 | 120 | If you have any questions or need help, please [open an issue](https://github.com/ghostlexly/ultimate-typescript-starter-kit/issues) or contact our team at contact@lunisoft.fr. 121 | 122 | --- 123 | 124 | Happy coding! 🎉 Don't forget to star ⭐ this repo if you find it useful! 125 | -------------------------------------------------------------------------------- /backend/.cursorrules: -------------------------------------------------------------------------------- 1 | You are an expert in MEAN Stack development, focusing on scalable and secure web applications. 2 | 3 | Key Principles 4 | 5 | - Build modular and maintainable applications. 6 | - Use TypeScript for both frontend and backend for consistency. 7 | - Follow RESTful API design principles. 8 | - Ensure security using best practices (e.g., HTTPS, JWT, CORS). 9 | - Optimize performance across the stack. 10 | 11 | TypeScript 12 | 13 | - Define data structures with interfaces for type safety. 14 | - Avoid `any` type; utilize the type system fully. 15 | - Organize files: imports, definition, implementation. 16 | - Use template strings for multi-line literals. 17 | - Utilize optional chaining and nullish coalescing. 18 | - Use standalone components when applicable. 19 | 20 | File Naming Conventions 21 | 22 | - `*.service.ts` for Services 23 | - `*.spec.ts` for Tests 24 | - `*.controller.ts` for Express Controllers 25 | - `*.routes.ts` for Express Routes 26 | - All files use kebab-case. 27 | 28 | Code Style 29 | 30 | - Use double quotes for string literals. 31 | - Use `const` for immutable variables. 32 | - Use template strings for string interpolation. 33 | - Add comments to explain why behind the code in more complex functions. 34 | - Use consistent naming conventions for variables, functions, and components. 35 | - Keep functions small and focused (single responsibility). 36 | - Handle errors and edge cases gracefully. 37 | - Use descriptive names for arrays and objects. 38 | 39 | Node.js & Express 40 | 41 | - Organize files for clarity: controllers, routes, services, usecases. 42 | - Use middleware for reusable logic. 43 | - Implement centralized error handling using middleware. 44 | - Validate inputs with Zod in a separate `*.validators.ts` file. 45 | - Use `z.infer` for type inference in handlers. 46 | - Keep controllers small and focused (single responsibility). 47 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | audit.json 3 | .rollup.cache 4 | tsconfig.tsbuildinfo 5 | 6 | # compiled output 7 | /dist 8 | /node_modules 9 | /src/generated 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | pnpm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | lerna-debug.log* 19 | 20 | # OS 21 | .DS_Store 22 | 23 | # Tests 24 | /coverage 25 | /.nyc_output 26 | 27 | # IDEs and editors 28 | /.idea 29 | .project 30 | .classpath 31 | .c9/ 32 | *.launch 33 | .settings/ 34 | *.sublime-workspace 35 | 36 | # IDE - VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | 43 | # Static assets 44 | /src/static/assets/css/output.css -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "bracketSameLine": false 6 | } 7 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # -- BUILDER 2 | FROM node:22.9.0-bullseye-slim AS builder 3 | WORKDIR /usr/src/app 4 | 5 | # Install dependencies 6 | COPY . . 7 | RUN npm install 8 | 9 | # Build project 10 | RUN npx prisma generate 11 | RUN npm run build 12 | 13 | # -- RUNNER 14 | FROM node:22.9.0-bullseye-slim AS base 15 | WORKDIR /usr/src/app 16 | 17 | # Update apt and install security updates 18 | RUN apt update && \ 19 | apt upgrade -y && \ 20 | apt install -y ca-certificates && \ 21 | apt clean 22 | 23 | # Install required packages 24 | RUN apt install -y wget xz-utils procps 25 | 26 | # Install ffmpeg using apt 27 | RUN apt install -y ffmpeg 28 | 29 | # Install production-only dependencies (this will ignore devDependencies) 30 | COPY ./package.json ./ 31 | COPY ./package-lock.json ./ 32 | COPY --from=builder /usr/src/app/node_modules ./node_modules 33 | 34 | # Playwright's dependencies 35 | RUN npx playwright install chromium --with-deps 36 | 37 | # Copy project specific files 38 | COPY --from=builder /usr/src/app/dist ./dist 39 | COPY ./prisma ./prisma 40 | 41 | ENV NODE_ENV=production 42 | CMD npm run start 43 | -------------------------------------------------------------------------------- /backend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import jsPlugin from "@eslint/js"; 3 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | 6 | export default [ 7 | { 8 | files: ["**/*.{js,mjs,cjs,ts}"], 9 | languageOptions: { 10 | globals: { ...globals.node, ...globals.jest }, 11 | parser: tsParser, 12 | }, 13 | plugins: { 14 | js: jsPlugin, 15 | "@typescript-eslint": tsPlugin, 16 | }, 17 | rules: { 18 | ...jsPlugin.configs.recommended.rules, 19 | ...tsPlugin.configs.recommended.rules, 20 | "@typescript-eslint/no-unused-vars": [ 21 | "error", 22 | { argsIgnorePattern: "^_" }, 23 | ], 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "@typescript-eslint/no-explicit-any": "warn", 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["js", "json"], 3 | rootDir: "dist/src", 4 | collectCoverageFrom: ["**/*.js"], 5 | coverageDirectory: "../coverage", 6 | setupFilesAfterEnv: ["/common/test/jest-setup.js"], 7 | testEnvironment: "node", 8 | }; 9 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,ejs", 4 | "exec": "npm run build && npm run start" 5 | } 6 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "node scripts/build.mjs", 10 | "typecheck": "tsc --noEmit", 11 | "start": "node dist/src/server.js", 12 | "start:dev": "nodemon", 13 | "cli": "node dist/src/cli/cli.js", 14 | "madge": "madge . -c --ts-config ./tsconfig.json --extensions ts --warning", 15 | "format": "prettier --write \"src/**/*.ts\"", 16 | "build:css": "tailwindcss -i ./src/static/assets/css/input.css -o ./src/static/assets/css/output.css", 17 | "test": "NODE_ENV=test APP_PORT=3001 jest --forceExit --runInBand --detectOpenHandles --testTimeout=30000", 18 | "sentry:sourcemaps": "sentry-cli sourcemaps inject --org lunisoft --project dispomenage-backend ./dist && sentry-cli sourcemaps upload --org lunisoft --project dispomenage-backend ./dist" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-s3": "^3.588.0", 22 | "@aws-sdk/s3-request-presigner": "^3.588.0", 23 | "@casl/ability": "^6.7.1", 24 | "@playwright/browser-chromium": "^1.49.1", 25 | "@prisma/client": "^6.7.0", 26 | "@sentry/cli": "^2.43.0", 27 | "@sentry/node": "^9.12.0", 28 | "async-retry": "^1.3.3", 29 | "bcrypt": "^5.1.1", 30 | "bullmq": "^5.20.0", 31 | "chalk": "4.1.2", 32 | "commander": "^12.1.0", 33 | "cookie-parser": "^1.4.7", 34 | "cors": "^2.8.5", 35 | "cron": "^3.2.1", 36 | "date-fns": "^4.1.0", 37 | "dotenv": "^16.4.5", 38 | "ejs": "^3.1.10", 39 | "eventemitter2": "^6.4.9", 40 | "express": "^4.21.1", 41 | "express-rate-limit": "^7.4.1", 42 | "file-type": "16.5.4", 43 | "glob": "^11.0.0", 44 | "helmet": "^8.0.0", 45 | "i18n-iso-countries": "^7.11.1", 46 | "i18next": "^23.11.3", 47 | "ioredis": "^5.4.1", 48 | "libphonenumber-js": "^1.11.1", 49 | "lodash": "^4.17.21", 50 | "multer": "^1.4.5-lts.1", 51 | "papaparse": "^5.4.1", 52 | "passport": "^0.7.0", 53 | "passport-http-bearer": "^1.0.1", 54 | "passport-jwt": "^4.0.1", 55 | "playwright": "^1.44.0", 56 | "reflect-metadata": "^0.2.2", 57 | "swagger-jsdoc": "^6.2.8", 58 | "swagger-ui-express": "^5.0.1", 59 | "validator": "^13.12.0", 60 | "winston": "^3.17.0", 61 | "winston-daily-rotate-file": "^5.0.0", 62 | "zod": "^3.23.6" 63 | }, 64 | "devDependencies": { 65 | "@eslint/js": "^9.17.0", 66 | "@faker-js/faker": "^9.0.3", 67 | "@types/bcrypt": "^5.0.2", 68 | "@types/cookie-parser": "^1.4.8", 69 | "@types/cors": "^2.8.17", 70 | "@types/ejs": "^3.1.5", 71 | "@types/express": "^4.17.13", 72 | "@types/jest": "^29.5.14", 73 | "@types/jsonwebtoken": "^9.0.9", 74 | "@types/lodash": "^4.17.7", 75 | "@types/multer": "^1.4.11", 76 | "@types/node": "18.11.18", 77 | "@types/papaparse": "^5.3.14", 78 | "@types/passport-http-bearer": "^1.0.41", 79 | "@types/supertest": "^6.0.2", 80 | "@types/validator": "^13.12.2", 81 | "@typescript-eslint/eslint-plugin": "^8.19.0", 82 | "@typescript-eslint/parser": "^8.19.0", 83 | "eslint": "^9.17.0", 84 | "eslint-config-prettier": "^8.3.0", 85 | "eslint-plugin-prettier": "^4.0.0", 86 | "jest": "^29.7.0", 87 | "madge": "^8.0.0", 88 | "nodemon": "^3.1.7", 89 | "prettier": "^3.5.3", 90 | "prisma": "^6.7.0", 91 | "supertest": "^7.0.0", 92 | "tailwindcss": "^3.4.16", 93 | "tsc-alias": "^1.8.10", 94 | "typescript": "^5.6.3" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250519092406_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('ADMIN', 'CUSTOMER'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Account" ( 6 | "id" TEXT NOT NULL, 7 | "role" "Role" NOT NULL, 8 | 9 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Session" ( 14 | "id" TEXT NOT NULL, 15 | "expiresAt" TIMESTAMP(3) NOT NULL, 16 | "accountId" TEXT NOT NULL, 17 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 | "updatedAt" TIMESTAMP(3) NOT NULL, 19 | 20 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 21 | ); 22 | 23 | -- CreateTable 24 | CREATE TABLE "Customer" ( 25 | "id" TEXT NOT NULL, 26 | "email" TEXT NOT NULL, 27 | "password" TEXT, 28 | "phone" TEXT, 29 | "accountId" TEXT NOT NULL, 30 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | "updatedAt" TIMESTAMP(3) NOT NULL, 32 | 33 | CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") 34 | ); 35 | 36 | -- CreateTable 37 | CREATE TABLE "Admin" ( 38 | "id" TEXT NOT NULL, 39 | "email" TEXT NOT NULL, 40 | "password" TEXT NOT NULL, 41 | "accountId" TEXT NOT NULL, 42 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 43 | "updatedAt" TIMESTAMP(3) NOT NULL, 44 | 45 | CONSTRAINT "Admin_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "Media" ( 50 | "id" TEXT NOT NULL, 51 | "fileName" TEXT NOT NULL, 52 | "key" TEXT NOT NULL, 53 | "mimeType" TEXT NOT NULL, 54 | "size" INTEGER NOT NULL, 55 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 56 | "updatedAt" TIMESTAMP(3) NOT NULL, 57 | 58 | CONSTRAINT "Media_pkey" PRIMARY KEY ("id") 59 | ); 60 | 61 | -- CreateTable 62 | CREATE TABLE "Country" ( 63 | "id" TEXT NOT NULL, 64 | "countryName" TEXT NOT NULL, 65 | "iso2Code" TEXT NOT NULL, 66 | "iso3Code" TEXT NOT NULL, 67 | "num3Code" TEXT NOT NULL, 68 | "inseeCode" TEXT, 69 | "continent" TEXT, 70 | "continentName" TEXT, 71 | "currencyCode" TEXT, 72 | "population" INTEGER DEFAULT 0, 73 | 74 | CONSTRAINT "Country_pkey" PRIMARY KEY ("id") 75 | ); 76 | 77 | -- CreateIndex 78 | CREATE UNIQUE INDEX "Customer_email_key" ON "Customer"("email"); 79 | 80 | -- CreateIndex 81 | CREATE UNIQUE INDEX "Customer_accountId_key" ON "Customer"("accountId"); 82 | 83 | -- CreateIndex 84 | CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email"); 85 | 86 | -- CreateIndex 87 | CREATE UNIQUE INDEX "Admin_accountId_key" ON "Admin"("accountId"); 88 | 89 | -- CreateIndex 90 | CREATE UNIQUE INDEX "Country_iso2Code_key" ON "Country"("iso2Code"); 91 | 92 | -- CreateIndex 93 | CREATE UNIQUE INDEX "Country_iso3Code_key" ON "Country"("iso3Code"); 94 | 95 | -- CreateIndex 96 | CREATE UNIQUE INDEX "Country_num3Code_key" ON "Country"("num3Code"); 97 | 98 | -- AddForeignKey 99 | ALTER TABLE "Session" ADD CONSTRAINT "Session_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 100 | 101 | -- AddForeignKey 102 | ALTER TABLE "Customer" ADD CONSTRAINT "Customer_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 103 | 104 | -- AddForeignKey 105 | ALTER TABLE "Admin" ADD CONSTRAINT "Admin_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 106 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client" 3 | binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x", "linux-arm64-openssl-1.1.x"] 4 | output = "../src/generated/prisma/client" 5 | 6 | // Optional Prisma Client Generator configuration 7 | runtime = "nodejs" 8 | moduleFormat = "cjs" 9 | generatedFileExtension = "ts" 10 | } 11 | 12 | datasource db { 13 | provider = "postgresql" 14 | url = env("APP_DATABASE_CONNECTION_URL") 15 | } 16 | 17 | // ------------------------------------------------------ 18 | // Authentications 19 | // ------------------------------------------------------ 20 | 21 | enum Role { 22 | ADMIN 23 | CUSTOMER 24 | } 25 | 26 | model Account { 27 | id String @id @default(uuid()) 28 | role Role 29 | session Session[] 30 | 31 | customer Customer? 32 | admin Admin? 33 | } 34 | 35 | model Session { 36 | id String @id @default(uuid()) 37 | expiresAt DateTime 38 | account Account @relation(fields: [accountId], references: [id]) 39 | accountId String 40 | 41 | createdAt DateTime @default(now()) 42 | updatedAt DateTime @updatedAt 43 | } 44 | 45 | model Customer { 46 | id String @id @default(uuid()) 47 | email String @unique 48 | password String? 49 | phone String? 50 | 51 | account Account @relation(fields: [accountId], references: [id]) 52 | accountId String @unique 53 | 54 | createdAt DateTime @default(now()) 55 | updatedAt DateTime @updatedAt 56 | } 57 | 58 | model Admin { 59 | id String @id @default(uuid()) 60 | email String @unique 61 | password String 62 | 63 | account Account @relation(fields: [accountId], references: [id]) 64 | accountId String @unique 65 | 66 | createdAt DateTime @default(now()) 67 | updatedAt DateTime @updatedAt 68 | } 69 | 70 | // ------------------------------------------------------ 71 | // Common models 72 | // ------------------------------------------------------ 73 | 74 | model Media { 75 | id String @id @default(uuid()) 76 | fileName String 77 | key String 78 | mimeType String 79 | size Int 80 | createdAt DateTime @default(now()) 81 | updatedAt DateTime @updatedAt 82 | } 83 | 84 | // data from https://www.insee.fr/fr/information/7766585 -> Pays et territoires étrangers 85 | model Country { 86 | id String @id @default(uuid()) 87 | countryName String 88 | iso2Code String @unique // ISO 3166-1 alpha-2 code (FR, DE, ES, IT, etc..) 89 | iso3Code String @unique // ISO 3166-1 alpha-3 code (FRA, DEU, ESP, ITA, etc..) 90 | num3Code String @unique // ISO 3166-1 numeric-3 code (250, 276, 724, 380, etc..) 91 | inseeCode String? 92 | continent String? 93 | continentName String? 94 | currencyCode String? 95 | population Int? @default(0) 96 | } 97 | -------------------------------------------------------------------------------- /backend/scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | import fs from "fs/promises"; 3 | import { glob } from "glob"; 4 | import path from "path"; 5 | 6 | class Builder { 7 | /** 8 | * Do command line command. 9 | */ 10 | asyncSpawn = (command, options = {}) => { 11 | return new Promise((resolve, reject) => { 12 | const childProcess = spawn(command, { 13 | shell: true, 14 | stdio: ["inherit", "inherit", "inherit"], 15 | ...options, 16 | }); 17 | 18 | childProcess.on("exit", (code) => { 19 | if (code !== 0) { 20 | reject(`Child process exited with code ${code}.`); 21 | } 22 | resolve(true); 23 | }); 24 | 25 | childProcess.on("error", (error) => { 26 | reject(`Error on process spawn: ${error.message}.`); 27 | }); 28 | 29 | process.on("exit", () => { 30 | childProcess.kill(); 31 | }); 32 | }); 33 | }; 34 | 35 | clearDist = async () => { 36 | await fs.rm(path.join("dist"), { recursive: true, force: true }); 37 | }; 38 | 39 | /** 40 | * Copy non-TypeScript files to the dist directory 41 | */ 42 | copyNonTsFiles = async () => { 43 | const nonTsFiles = await glob("**/*", { 44 | ignore: ["**/*.{ts,tsx,js,jsx}", "**/node_modules/**", "dist/**"], 45 | nodir: true, 46 | cwd: "src", 47 | }); 48 | 49 | for (const file of nonTsFiles) { 50 | const sourcePath = path.join("src", file); 51 | const destPath = path.join("dist", "src", file); 52 | 53 | // Ensure the destination directory exists 54 | await fs.mkdir(path.dirname(destPath), { recursive: true }); 55 | 56 | // Copy the file 57 | await fs.copyFile(sourcePath, destPath); 58 | } 59 | }; 60 | 61 | /** 62 | * Build TypeScript files 63 | */ 64 | buildJs = async () => { 65 | try { 66 | await this.asyncSpawn("npx tsc --build --incremental"); 67 | await this.asyncSpawn("npx tsc-alias"); 68 | } catch (error) { 69 | console.error("Error building JavaScript:", error); 70 | throw error; 71 | } 72 | }; 73 | 74 | /** 75 | * Build CSS files 76 | */ 77 | buildCss = async () => { 78 | await this.asyncSpawn("npm run build:css"); 79 | }; 80 | 81 | /** 82 | * Main build function 83 | */ 84 | build = async () => { 85 | try { 86 | await this.clearDist(); 87 | 88 | console.log("Building..."); 89 | await this.buildCss(); 90 | await Promise.all([this.buildJs(), this.copyNonTsFiles()]); 91 | 92 | console.log("Build completed successfully"); 93 | } catch (error) { 94 | console.error("Build failed:", error); 95 | process.exit(1); 96 | } 97 | }; 98 | } 99 | 100 | const builder = new Builder(); 101 | builder.build(); 102 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import "./instrument"; 2 | import path from "path"; 3 | import "dotenv/config"; 4 | import "reflect-metadata"; 5 | import { exceptionsMiddleware } from "@/common/middlewares/exceptions.middleware"; 6 | import { rewriteIpAddressMiddleware } from "@/common/middlewares/rewrite-ip-address.middleware"; 7 | import { trimMiddleware } from "@/common/middlewares/trim.middleware"; 8 | import { unknownRoutesMiddleware } from "@/common/middlewares/unknown-routes.middleware"; 9 | import { apiRoutes } from "@/routes"; 10 | import { globalThrottler } from "@/common/throttlers/global.throttler"; 11 | import { Logger } from "@/common/utils/logger"; 12 | import cors from "cors"; 13 | import express from "express"; 14 | import helmet from "helmet"; 15 | import { initializeCrons } from "./common/cron"; 16 | import { initializeJwtStrategy } from "./modules/auth/strategies/jwt.strategy"; 17 | import cookieParser from "cookie-parser"; 18 | import * as Sentry from "@sentry/node"; 19 | import { initializeZod } from "./common/utils/zod"; 20 | 21 | const bootstrap = async () => { 22 | const app = express(); 23 | const logger = new Logger("app"); 24 | 25 | // Log bootstrap time 26 | const bootstrapStartTime = Date.now(); 27 | 28 | // Disable `x-powered-by` header for security reasons 29 | app.disable("x-powered-by"); 30 | 31 | // Set view engine to ejs 32 | app.set("view engine", "ejs"); 33 | 34 | // We parse the body of the request to be able to access it 35 | // @example: app.post('/', (req) => req.body.prop) 36 | app.use(express.json()); 37 | 38 | // We parse the Content-Type `application/x-www-form-urlencoded` 39 | // ex: key1=value1&key2=value2. 40 | // to be able to access these forms's values in req.body 41 | app.use(express.urlencoded({ extended: true })); 42 | 43 | // Parse cookies 44 | app.use(cookieParser()); 45 | 46 | // Helmet is a collection of middlewares functions that set security-related headers 47 | app.use( 48 | helmet({ 49 | crossOriginResourcePolicy: false, // We are already using CORS 50 | }) 51 | ); 52 | 53 | // Add CORS middleware 54 | app.use(cors()); // This will allow all origins in development 55 | 56 | // Rewrite ip address from cloudflare or other proxies 57 | app.use(rewriteIpAddressMiddleware); 58 | 59 | // We trim the body of the incoming requests to remove any leading or trailing whitespace 60 | app.use(trimMiddleware); 61 | 62 | // Passport strategies 63 | await initializeJwtStrategy(); 64 | 65 | // Crons 66 | initializeCrons(); 67 | 68 | // Zod 69 | initializeZod(); 70 | 71 | // Static assets 72 | // We are using them in the PDF views 73 | app.use("/static", express.static(path.join(__dirname, "static"))); 74 | 75 | // Routes 76 | app.use("/api", globalThrottler, apiRoutes); 77 | 78 | // ---------------------------------------- 79 | // Unknown routes handler 80 | // @important: Should be just before the last `app.use` 81 | // ---------------------------------------- 82 | app.use(unknownRoutesMiddleware); 83 | 84 | // ---------------------------------------- 85 | // Errors handler 86 | // @important: Should be the last `app.use` 87 | // ---------------------------------------- 88 | Sentry.setupExpressErrorHandler(app); 89 | app.use(exceptionsMiddleware); 90 | 91 | // Log bootstrap time 92 | if (process.env.NODE_ENV !== "test") { 93 | logger.info(`🕒 Bootstrap time: ${Date.now() - bootstrapStartTime}ms`); 94 | } 95 | 96 | return app; 97 | }; 98 | 99 | if (require.main === module) { 100 | bootstrap(); 101 | } 102 | 103 | export { bootstrap }; 104 | -------------------------------------------------------------------------------- /backend/src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import path from "path"; 3 | import { getAppDir } from "@/common/utils/app-dir"; 4 | import { glob } from "glob"; 5 | import { Logger } from "@/common/utils/logger"; 6 | import chalk from "chalk"; 7 | import { appService } from "@/common/services/app.service"; 8 | 9 | const logger = new Logger("cli"); 10 | const program = new Command(); 11 | 12 | // Configure help output 13 | program.configureHelp({ 14 | subcommandTerm: (cmd) => chalk.yellow(cmd.name()), 15 | subcommandDescription: (cmd) => chalk.green(cmd.description()), 16 | }); 17 | 18 | // Function to dynamically load commands 19 | async function loadCommands() { 20 | const commandsPath = path.join(getAppDir(), "cli", "commands"); 21 | 22 | const files = await glob("**/*.command.js", { cwd: commandsPath }); 23 | 24 | await Promise.all( 25 | files.map(async (file) => { 26 | const { default: commandModule } = await import( 27 | path.join(commandsPath, file) 28 | ); 29 | 30 | if (typeof commandModule === "function") { 31 | commandModule(program); 32 | } 33 | }) 34 | ); 35 | } 36 | 37 | // Load commands and start the program 38 | (async () => { 39 | await loadCommands(); 40 | 41 | // Add a default action to handle the case where no command is specified 42 | program.action(() => { 43 | logger.info("No command specified."); 44 | program.outputHelp(); 45 | }); 46 | 47 | // Parse the command line arguments and await the result 48 | await program.parseAsync(process.argv); 49 | 50 | // Only shutdown after command completion 51 | await appService.shutdownGracefully(); 52 | })(); 53 | -------------------------------------------------------------------------------- /backend/src/cli/commands/create-admin-account.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import { prisma } from "@/common/database/prisma"; 4 | import { authService } from "@/common/services/auth.service"; 5 | 6 | const LOGGER = new Logger("create-admin-account-command"); 7 | 8 | const setupCommand = (program: Command): void => { 9 | program 10 | .command("create:admin-account") 11 | .description("Create an admin account.") 12 | .argument("", "Email") 13 | .argument("", "Password") 14 | .action(runCommand); 15 | }; 16 | 17 | const runCommand = async (email: string, password: string): Promise => { 18 | const hashedPassword = await authService.hashPassword({ password }); 19 | 20 | await prisma.admin.create({ 21 | data: { 22 | email: email, 23 | password: hashedPassword, 24 | account: { 25 | create: { 26 | role: "ADMIN", 27 | }, 28 | }, 29 | }, 30 | }); 31 | 32 | LOGGER.info("Account created successfully."); 33 | process.exit(0); 34 | }; 35 | 36 | export default setupCommand; 37 | -------------------------------------------------------------------------------- /backend/src/cli/commands/redis-clear.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import { redisService } from "@/common/services/redis.service"; 4 | 5 | const LOGGER = new Logger("redis-clear-command"); 6 | 7 | const setupCommand = (program: Command): void => { 8 | program 9 | .command("redis:clear") 10 | .description("Clear Redis cache.") 11 | .action(runCommand); 12 | }; 13 | 14 | const runCommand = async (): Promise => { 15 | try { 16 | await redisService.flushall(); 17 | LOGGER.info("All Redis keys have been cleared successfully."); 18 | } catch (error) { 19 | LOGGER.error("An error occured during Redis keys clearing.", { 20 | error: error?.message, 21 | stack: error?.stack, 22 | }); 23 | } 24 | 25 | process.exit(0); 26 | }; 27 | 28 | export default setupCommand; 29 | -------------------------------------------------------------------------------- /backend/src/cli/commands/seed-countries.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import papaparse from "papaparse"; 4 | import { wolfios } from "@/common/utils/wolfios"; 5 | import { prisma } from "@/common/database/prisma"; 6 | 7 | const LOGGER = new Logger("seed-countries-command"); 8 | 9 | const setupCommand = (program: Command): void => { 10 | program 11 | .command("seed:countries") 12 | .description("Seed countries list into the database.") 13 | .action(runCommand); 14 | }; 15 | 16 | type InseeRow = { 17 | COG: string; 18 | ACTUAL: string; 19 | CRPAY: string; 20 | ANI: string; 21 | LIBCOG: string; 22 | LIBENR: string; 23 | CODEISO2: string; 24 | CODEISO3: string; 25 | CODENUM3: string; 26 | }; 27 | 28 | const runCommand = async (): Promise => { 29 | try { 30 | const inseeService = await createInseeService(); 31 | const geonames = await getGeonames(); 32 | 33 | for (const country of geonames) { 34 | // ------------------------------------- 35 | // find the insee data to get insee country code 36 | // ------------------------------------- 37 | const insee = inseeService.getInseeByIso2Code({ 38 | iso2Code: country.countryCode, 39 | }); 40 | 41 | if (!insee) { 42 | LOGGER.warn(`No insee data found for ${country.countryCode}`); 43 | } 44 | 45 | // ------------------------------------- 46 | // insert country into database 47 | // ------------------------------------- 48 | await prisma.country.create({ 49 | data: { 50 | countryName: country.countryName, 51 | inseeCode: insee?.COG || null, 52 | iso2Code: country.countryCode, 53 | iso3Code: country.isoAlpha3, 54 | num3Code: country.isoNumeric, 55 | population: Number(country.population), 56 | continent: country.continent, 57 | continentName: country.continentName, 58 | currencyCode: country.currencyCode, 59 | }, 60 | }); 61 | 62 | LOGGER.debug(`Seed [${country.countryName}] successfully.`); 63 | } 64 | 65 | LOGGER.info(`Seeding finished successfully.`); 66 | } catch (error) { 67 | LOGGER.error("Error occured on seeding countries.", { 68 | error: error?.message, 69 | stack: error?.stack, 70 | }); 71 | } 72 | 73 | process.exit(0); 74 | }; 75 | 76 | const getGeonames = async () => { 77 | LOGGER.info("Downloading geonames.org data..."); 78 | const countries = await wolfios("http://api.geonames.org/countryInfoJSON", { 79 | method: "GET", 80 | params: { 81 | username: "ghostlexly", 82 | formatted: "true", 83 | style: "full", 84 | lang: "FR", // 👈 set your country language code here for country name translation 85 | }, 86 | }) 87 | .then(async (res) => await res.json()) 88 | .then((data) => data.geonames); 89 | 90 | return countries; 91 | }; 92 | 93 | const createInseeService = async () => { 94 | LOGGER.info("Downloading insee data..."); 95 | const response = await wolfios( 96 | "https://www.insee.fr/fr/statistiques/fichier/7766585/v_pays_territoire_2024.csv" 97 | ).then(async (res) => await res.text()); 98 | 99 | // -- parse csv data 100 | const { data: inseeData } = papaparse.parse(response, { 101 | header: true, 102 | skipEmptyLines: true, 103 | }); 104 | 105 | const getInseeByIso2Code = ({ iso2Code }: { iso2Code: string }) => 106 | inseeData.find((item) => item.CODEISO2 === iso2Code); 107 | 108 | return { 109 | getInseeData: () => inseeData, 110 | getInseeByIso2Code, 111 | }; 112 | }; 113 | 114 | export default setupCommand; 115 | -------------------------------------------------------------------------------- /backend/src/cli/commands/test.command.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { Logger } from "@/common/utils/logger"; 3 | 4 | const LOGGER = new Logger("test-command"); 5 | 6 | const setupCommand = (program: Command): void => { 7 | program 8 | .command("test:split-text") 9 | .description("Split a text into an array.") 10 | .option("--first", "Limit to first item") 11 | .option("-s, --separator ", "Specify separator character", ",") 12 | .argument("", "Text to split") 13 | .action(runCommand); 14 | }; 15 | 16 | type CommandOptions = { 17 | first?: boolean; 18 | separator: string; 19 | }; 20 | 21 | const runCommand = async ( 22 | text: string, 23 | options: CommandOptions 24 | ): Promise => { 25 | const limit = options.first ? 1 : undefined; 26 | const separator = options.separator; 27 | 28 | const splitedText = text.split(separator, limit).map((part) => part.trim()); 29 | 30 | LOGGER.info("Splited text successfully", { 31 | splitedText, 32 | }); 33 | 34 | process.exit(0); 35 | }; 36 | 37 | export default setupCommand; 38 | -------------------------------------------------------------------------------- /backend/src/common/cron/index.ts: -------------------------------------------------------------------------------- 1 | export const initializeCrons = () => { 2 | // -- We don't want to run crons in the test environment 3 | if (process.env.NODE_ENV === "test") return; 4 | 5 | import("./media-delete-orphans.cron"); 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/common/cron/media-delete-orphans.cron.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@/common/utils/logger"; 2 | import { CronJob } from "cron"; 3 | import { prisma } from "@/common/database/prisma"; 4 | import { dateUtils } from "../utils/date"; 5 | 6 | const logger = new Logger("mediaDeleteOrphansCron"); 7 | 8 | /** 9 | * Remove orphan media records. 10 | * An orphan media record is a media record that is not linked to any other record. 11 | */ 12 | new CronJob( 13 | "0 0 */3 * * *", 14 | async () => { 15 | try { 16 | // -- Get the orphan media records 17 | const orphanMedias = await prisma.media.findMany({ 18 | where: { 19 | AND: [ 20 | // { 21 | // housekeeperAvatar: null, 22 | // }, 23 | // { 24 | // housekeeperDocumentsMedias: { 25 | // none: {}, 26 | // }, 27 | // }, 28 | ], 29 | 30 | createdAt: { 31 | lt: dateUtils.sub(new Date(), { hours: 24 }), // older than 24 hours records 32 | }, 33 | }, 34 | }); 35 | 36 | // -- Delete the orphan media records 37 | for (const orphanMedia of orphanMedias) { 38 | logger.debug( 39 | `Deleting orphan media #${orphanMedia.id} with key [${orphanMedia.key}]...` 40 | ); 41 | 42 | // -- Get the record from the database 43 | const media = await prisma.media.findUnique({ 44 | where: { 45 | id: orphanMedia.id, 46 | }, 47 | }); 48 | 49 | if (!media) { 50 | logger.error( 51 | `Error deleting orphan media #${orphanMedia.id}: Media to delete cannot be found.` 52 | ); 53 | throw new Error("Media to delete cannot be found."); 54 | } 55 | 56 | // -- Delete the record from the database 57 | await prisma.media 58 | .delete({ 59 | where: { 60 | id: media.id, 61 | }, 62 | }) 63 | .catch((err) => { 64 | logger.error( 65 | `Error deleting orphan media #${media.id}: ${err.message}` 66 | ); 67 | }); 68 | } 69 | 70 | logger.debug("All orphan medias are been removed successfully."); 71 | } catch (error) { 72 | logger.error("Error during orphan media removal.", { 73 | error: error?.message, 74 | stack: error?.stack, 75 | }); 76 | } 77 | }, 78 | null, 79 | true, 80 | "Europe/Paris" 81 | ); 82 | -------------------------------------------------------------------------------- /backend/src/common/database/prisma.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient } from "@/generated/prisma/client"; 2 | import { s3Service } from "../storage/s3"; 3 | 4 | export type ExtendedPrismaClient = typeof prisma; 5 | 6 | export type PrismaTransactionClient = Omit< 7 | ExtendedPrismaClient, 8 | "$extends" | "$transaction" | "$disconnect" | "$connect" | "$on" | "$use" 9 | >; 10 | 11 | const prisma = new PrismaClient() 12 | .$extends({ 13 | model: { 14 | $allModels: { 15 | async findManyAndCount( 16 | this: Model, 17 | args: Prisma.Exact> 18 | ): Promise<{ 19 | data: Prisma.Result; 20 | count: number; 21 | }> { 22 | const [data, count] = await Promise.all([ 23 | (this as any).findMany(args), 24 | (this as any).count({ where: (args as any).where }), 25 | ]); 26 | 27 | return { data, count }; 28 | }, 29 | }, 30 | }, 31 | }) 32 | 33 | .$extends({ 34 | query: { 35 | media: { 36 | delete: async ({ args, query }) => { 37 | // -- Fetch the media record to get the key 38 | const media = await prisma.media.findUnique({ 39 | where: { id: args.where.id }, 40 | }); 41 | 42 | // -- Run the query and throw an error if the query fails 43 | const queryResult = await query(args); 44 | 45 | // -- The record was deleted successfully... 46 | if (media) { 47 | // Delete the file from S3 48 | await s3Service.deleteFile({ key: media.key }); 49 | } 50 | 51 | return queryResult; 52 | }, 53 | }, 54 | }, 55 | }); 56 | 57 | export { prisma }; 58 | -------------------------------------------------------------------------------- /backend/src/common/events/events.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter2 } from "eventemitter2"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import path from "path"; 4 | import { getAppDir } from "@/common/utils/app-dir"; 5 | import { glob } from "glob"; 6 | import { env } from "@/config"; 7 | 8 | const logger = new Logger("initializeEventEmitter"); 9 | 10 | export type AppEvents = { 11 | "test.event": (data: string) => void; 12 | "cart.updated": (data: { cartId: string }) => void; 13 | "payment.updated": (data: { payment: any }) => void; 14 | "payment.created": (data: { payment: any }) => void; 15 | "booking.created": (data: { bookingId: string; cartId: string }) => void; 16 | "booking.updated": (data: { bookingId: string; cartId: string }) => void; 17 | "booking.deleted": (data: { bookingId: string; cartId: string }) => void; 18 | "booking.completed": (data: { bookingId: string; cartId: string }) => void; 19 | }; 20 | 21 | const eventBus = new EventEmitter2({ 22 | wildcard: true, 23 | delimiter: ".", 24 | maxListeners: 20, 25 | }); 26 | 27 | const initializeEventEmitter = async () => { 28 | try { 29 | // -- get modules path 30 | const modulesPath = path.join(getAppDir(), "modules"); 31 | 32 | // -- get all listeners files 33 | const files = await glob("**/*.listener.{ts,js}", { 34 | cwd: modulesPath, 35 | }); 36 | 37 | // -- load all listeners 38 | await Promise.all(files.map((file) => loadListener(modulesPath, file))); 39 | } catch (error) { 40 | logger.error("Failed to initialize EventEmitter !", { 41 | error: error?.message, 42 | stack: error?.stack, 43 | }); 44 | throw error; 45 | } 46 | }; 47 | 48 | const loadListener = async ( 49 | modulesPath: string, 50 | file: string 51 | ): Promise => { 52 | try { 53 | const filePath = path.join(modulesPath, file); 54 | await import(filePath); 55 | 56 | if (env.NODE_ENV !== "test") { 57 | logger.info(`Loaded [${file}] events listener(s).`); 58 | } 59 | } catch (error) { 60 | logger.error(`Failed to load listener ${file} !`, { 61 | error: error?.message, 62 | stack: error?.stack, 63 | }); 64 | } 65 | }; 66 | 67 | // Type safety pour nos événements 68 | export const emitEvent = ( 69 | event: K, 70 | data: Parameters[0] 71 | ): boolean => eventBus.emit(event, data); 72 | 73 | export const emitEventAsync = ( 74 | event: K, 75 | data: Parameters[0] 76 | ): Promise => { 77 | return eventBus.emitAsync(event, data); 78 | }; 79 | 80 | export const onEvent = ( 81 | event: K, 82 | listener: AppEvents[K] 83 | ): void => { 84 | eventBus.on(event, listener); 85 | }; 86 | 87 | export const offEvent = ( 88 | event: K, 89 | listener: AppEvents[K] 90 | ): void => { 91 | eventBus.off(event, listener); 92 | }; 93 | 94 | /** 95 | * Global event service singleton. 96 | * Using singleton pattern as we want to ensure all events 97 | * are handled through a single, application-wide event bus. 98 | */ 99 | export const eventsService = { 100 | bus: eventBus, 101 | initialize: initializeEventEmitter, 102 | emit: emitEvent, 103 | emitAsync: emitEventAsync, 104 | on: onEvent, 105 | off: offEvent, 106 | }; 107 | -------------------------------------------------------------------------------- /backend/src/common/exceptions/http-exception.ts: -------------------------------------------------------------------------------- 1 | type HttpExceptionParams = { 2 | status: number; 3 | message: string; 4 | code?: string; 5 | cause?: Error; 6 | }; 7 | 8 | /** 9 | * Advanced custom error class 10 | * The stack trace is only available in development mode and set automatically. 11 | * The cause is also only available in development mode but you need to pass the Error instance. 12 | * 13 | * @example 14 | * ```ts 15 | * try { 16 | * await stripeClient.charges.create({ amount }); 17 | * } catch (stripeError) { 18 | * throw new HttpException({ 19 | * status: 400, 20 | * code: 'PAYMENT_FAILED', 21 | * message: 'Payment processing failed', 22 | * cause: stripeError 23 | * }); 24 | * } 25 | */ 26 | export class HttpException extends Error { 27 | public readonly status: number; 28 | public readonly message: string; 29 | public readonly stack!: string; 30 | public readonly code?: string; 31 | public readonly cause?: Error; 32 | 33 | constructor({ status, message, code, cause }: HttpExceptionParams) { 34 | // -- validate 35 | if (!status || typeof status !== "number" || status < 100 || status > 599) { 36 | throw new Error("Invalid status code"); 37 | } 38 | 39 | // -- initialize 40 | super(message); 41 | this.status = status; 42 | this.code = code; 43 | this.message = message; 44 | this.cause = cause; 45 | 46 | // This maintains proper stack trace for where our error was thrown 47 | Error.captureStackTrace(this, HttpException); 48 | } 49 | 50 | public static badRequest( 51 | params: Omit 52 | ): HttpException { 53 | return new HttpException({ status: 400, ...params }); 54 | } 55 | 56 | public static unauthorized( 57 | params: Omit 58 | ): HttpException { 59 | return new HttpException({ status: 401, ...params }); 60 | } 61 | 62 | public static paymentRequired( 63 | params: Omit 64 | ): HttpException { 65 | return new HttpException({ status: 402, ...params }); 66 | } 67 | 68 | public static forbidden( 69 | params: Omit 70 | ): HttpException { 71 | return new HttpException({ status: 403, ...params }); 72 | } 73 | 74 | public static notFound( 75 | params: Omit 76 | ): HttpException { 77 | return new HttpException({ status: 404, ...params }); 78 | } 79 | 80 | public static methodNotAllowed( 81 | params: Omit 82 | ): HttpException { 83 | return new HttpException({ status: 405, ...params }); 84 | } 85 | 86 | public static notAcceptable( 87 | params: Omit 88 | ): HttpException { 89 | return new HttpException({ status: 406, ...params }); 90 | } 91 | 92 | public static conflict( 93 | params: Omit 94 | ): HttpException { 95 | return new HttpException({ status: 409, ...params }); 96 | } 97 | 98 | public static tooManyRequests( 99 | params: Omit 100 | ): HttpException { 101 | return new HttpException({ status: 429, ...params }); 102 | } 103 | 104 | public static internalServerError( 105 | params: Omit 106 | ): HttpException { 107 | return new HttpException({ status: 500, ...params }); 108 | } 109 | 110 | public static notImplemented( 111 | params: Omit 112 | ): HttpException { 113 | return new HttpException({ status: 501, ...params }); 114 | } 115 | 116 | public static badGateway( 117 | params: Omit 118 | ): HttpException { 119 | return new HttpException({ status: 502, ...params }); 120 | } 121 | 122 | public static serviceUnavailable( 123 | params: Omit 124 | ): HttpException { 125 | return new HttpException({ status: 503, ...params }); 126 | } 127 | 128 | public static gatewayTimeout( 129 | params: Omit 130 | ): HttpException { 131 | return new HttpException({ status: 504, ...params }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /backend/src/common/exceptions/validation-exception.ts: -------------------------------------------------------------------------------- 1 | type ValidationExceptionParams = { 2 | message: string; 3 | violations: any[]; 4 | cause?: Error; 5 | }; 6 | 7 | /** 8 | * Advanced custom error class 9 | * The stack trace is only available in development mode and set automatically. 10 | * The cause is also only available in development mode but you need to pass the Error instance. 11 | * 12 | * @example 13 | * ```ts 14 | * try { 15 | * await stripeClient.charges.create({ amount }); 16 | * } catch (stripeError) { 17 | * throw new ValidationException({ 18 | * status: 400, 19 | * code: 'PAYMENT_FAILED', 20 | * body: 'Payment processing failed', 21 | * cause: stripeError 22 | * }); 23 | * } 24 | */ 25 | export class ValidationException extends Error { 26 | public readonly status: number; 27 | public readonly message: string; 28 | public readonly violations: any[]; 29 | public readonly stack!: string; 30 | public readonly code?: string; 31 | public readonly cause?: Error; 32 | 33 | constructor({ message, cause, violations }: ValidationExceptionParams) { 34 | // -- initialize 35 | super(message); 36 | this.status = 400; 37 | this.code = "VALIDATION_ERROR"; 38 | this.message = message; 39 | this.cause = cause; 40 | this.violations = violations; 41 | 42 | // This maintains proper stack trace for where our error was thrown 43 | Error.captureStackTrace(this, ValidationException); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/common/guards/abilities.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbilityBuilder, 3 | AbilityTuple, 4 | MongoAbility, 5 | MongoQuery, 6 | createMongoAbility, 7 | } from "@casl/ability"; 8 | import { CustomAccount } from "@/common/types/request"; 9 | import { Request, Response, NextFunction } from "express"; 10 | import { HttpException } from "@/common/exceptions/http-exception"; 11 | 12 | /** 13 | * Define the abilities of the given account. 14 | * @param account 15 | * @returns 16 | */ 17 | const defineAbilitiesFor = async (account: CustomAccount | null) => { 18 | const abilities = new AbilityBuilder(createMongoAbility); 19 | 20 | if (account?.role === "ADMIN") { 21 | abilities.can("manage", "all"); // Can do anything 22 | } else if (account?.role === "CUSTOMER") { 23 | await customerAbilities(account, abilities); 24 | } 25 | 26 | return abilities.build({ 27 | detectSubjectType: (object) => object.type, 28 | }); 29 | }; 30 | 31 | /** 32 | * Define the abilities of the housekeeper. 33 | * @param account 34 | * @param abilities 35 | * @returns 36 | */ 37 | const customerAbilities = async ( 38 | account: CustomAccount, 39 | abilities: AbilityBuilder> 40 | ) => { 41 | // ADD ABILITIES HERE... 42 | // 43 | // @EXAMPLE 1 -- 44 | // -- 45 | // const housekeeperInformations = await prisma.housekeeperInformation.findFirst( 46 | // { 47 | // where: { 48 | // ownerId: account.housekeeper.id, 49 | // }, 50 | // } 51 | // ); 52 | // 53 | // if (housekeeperInformations) { 54 | // abilities.can(["read", "update"], "housekeeper-informations", ["id"], { 55 | // id: housekeeperInformations.id, 56 | // }); 57 | // } else { 58 | // abilities.can(["create"], "housekeeper-informations"); 59 | // } 60 | // 61 | // @EXAMPLE 2 -- 62 | // const housekeeperBankAccounts = await prisma.housekeeperBankAccount.findMany({ 63 | // where: { 64 | // ownerId: account.housekeeper.id, 65 | // }, 66 | // }); 67 | // 68 | // abilities.can(["create", "read"], "housekeeper-bank-account"); 69 | // 70 | // for (const bankAccount of housekeeperBankAccounts) { 71 | // abilities.can(["update", "delete"], "housekeeper-bank-account", ["id"], { 72 | // id: bankAccount.id, 73 | // }); 74 | // } 75 | }; 76 | 77 | /** 78 | * Check if the current logged-in user has the required abilities. 79 | * @param param0 80 | */ 81 | export const checkAbilities = async ({ 82 | req, 83 | method, 84 | type, 85 | object, 86 | throwError = false, 87 | }: { 88 | req: Request; 89 | method: string; 90 | type: string; 91 | object?: any; 92 | throwError?: boolean; 93 | }) => { 94 | const abilities = await defineAbilitiesFor(req.context?.account || null); 95 | 96 | if ( 97 | abilities.cannot(method, { 98 | type: type, 99 | ...object, 100 | }) 101 | ) { 102 | if (throwError) { 103 | throw new HttpException({ 104 | status: 403, 105 | message: "You are not allowed to access this resource.", 106 | }); 107 | } else { 108 | return false; 109 | } 110 | } 111 | 112 | return true; 113 | }; 114 | 115 | /** 116 | * Get the abilities of the current logged-in user programatically. 117 | * @param req 118 | * @param res 119 | * @param next 120 | * @returns 121 | */ 122 | export const getAbilities = (req: Request) => { 123 | if (!req.context?.account) { 124 | throw new HttpException({ 125 | status: 403, 126 | message: "You must be logged in to access this resource.", 127 | }); 128 | } 129 | 130 | return defineAbilitiesFor(req.context?.account); 131 | }; 132 | 133 | /** 134 | * Check if the current logged-in user has the required abilities. 135 | * @param method : method name (read, create, update, delete) 136 | * @param type : type of the resource (housekeeper-informations, housekeeper-avatar, housekeeper-document) 137 | * @param object : object to check (optional) (id, ownerId, etc.) 138 | * @returns 139 | */ 140 | export const abilitiesGuard = 141 | ({ 142 | method, 143 | type, 144 | object, 145 | }: { 146 | method: string; 147 | type: string; 148 | object?: (req: Request) => any; 149 | }) => 150 | async (req: Request, res: Response, next: NextFunction) => { 151 | // Evaluating 'object' option to dynamically build the verification object 152 | // If 'object' is a function, evaluate it with 'req' to get the object 153 | const objectToCheck = typeof object === "function" ? object(req) : object; 154 | 155 | try { 156 | const hasAccess = await checkAbilities({ 157 | req, 158 | method, 159 | type, 160 | object: objectToCheck, 161 | }); 162 | 163 | if (!hasAccess) { 164 | throw new HttpException({ 165 | status: 403, 166 | message: "You are not allowed to access this resource.", 167 | }); 168 | } 169 | 170 | return next(); 171 | } catch (err) { 172 | return next(err); 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /backend/src/common/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "@/generated/prisma/client"; 2 | import { Request, Response, NextFunction } from "express"; 3 | import { HttpException } from "@/common/exceptions/http-exception"; 4 | 5 | export const rolesGuard = 6 | (roles: Role[]) => (req: Request, res: Response, next: NextFunction) => { 7 | const account = req.context?.account; 8 | 9 | /** 10 | * Check if the user is logged in 11 | * otherwise, throw an Unauthorized error (401). 12 | * This status code indicates that the client is not authenticated. 13 | */ 14 | if (!account) { 15 | return next( 16 | HttpException.unauthorized({ 17 | message: 18 | "Authentication required. Please provide a valid access token.", 19 | }) 20 | ); 21 | } 22 | 23 | /** 24 | * Check if the user has the required role otherwise, throw a Forbidden error (403). 25 | * This status code indicates that the client is authenticated, 26 | * but it does not have the necessary permissions for the resource. 27 | */ 28 | if (!roles.includes(account.role)) { 29 | return next( 30 | HttpException.forbidden({ 31 | message: "You don't have permission to access this resource.", 32 | }) 33 | ); 34 | } 35 | 36 | next(); 37 | }; 38 | -------------------------------------------------------------------------------- /backend/src/common/guards/sessions.guard.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import passport from "passport"; 3 | import { HttpException } from "@/common/exceptions/http-exception"; 4 | import { CustomAccount } from "@/common/types/request"; 5 | 6 | /** 7 | Block everything if the user is not authenticated and the route is not public 8 | get the user from the token and add it to the request. 9 | Checks for JWT token in Authorization header first, then falls back to URL query params (?token=) 10 | @example app.get('/', (req) => req.context?.account) 11 | */ 12 | export const sessionsGuard = async ( 13 | req: Request, 14 | res: Response, 15 | next: NextFunction 16 | ) => { 17 | // Extract Authorization header 18 | const authHeader = req.headers.authorization; 19 | 20 | // If the authorization header is not present, try to get the token from the url query params or cookies 21 | if (!authHeader) { 22 | // Try to get the token from the url query params 23 | const queryToken = req.query?.["token"]; 24 | if (queryToken) { 25 | req.headers.authorization = `Bearer ${queryToken}`; 26 | } 27 | 28 | // Try to get the token from the cookies 29 | if (!queryToken) { 30 | const cookieToken = req.cookies?.["lunisoft_access_token"]; 31 | 32 | if (cookieToken) { 33 | req.headers.authorization = `Bearer ${cookieToken}`; 34 | } 35 | } 36 | } 37 | 38 | // Call passport authentication mechanism 39 | await passport.authenticate( 40 | "jwt", 41 | { session: false }, 42 | (err: Error, user: CustomAccount) => { 43 | // Handle middleware error 44 | if (err) { 45 | return next(err); 46 | } 47 | 48 | // Handle authentication failure 49 | // We provide 401 status code so the frontend can redirect to the login page 50 | if (!user) { 51 | return next( 52 | HttpException.unauthorized({ 53 | message: 54 | "Authentication required. Please provide a valid access token.", 55 | }) 56 | ); 57 | } 58 | 59 | // Handle authentication success 60 | req.context = { 61 | account: user, 62 | }; 63 | next(); 64 | } 65 | )(req, res, next); 66 | }; 67 | -------------------------------------------------------------------------------- /backend/src/common/locales/zod/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": { 3 | "invalid_type": "Type invalide: {{expected}} doit être fourni(e), mais {{received}} a été reçu(e).", 4 | "invalid_type_received_undefined": "Ce champ est obligatoire.", 5 | "invalid_literal": "La valeur doit être {{expected}}.", 6 | "unrecognized_keys": "Une ou plusieurs clé(s) non reconnue(s) dans l'objet: {{- keys}}.", 7 | "invalid_union": "Champ invalide.", 8 | "invalid_union_discriminator": "La valeur du discriminateur est invalide. Options attendus: {{- options}}.", 9 | "invalid_enum_value": "Valeur '{{received}}' n'existe pas dans les options: {{- options}}.", 10 | "invalid_arguments": "Fonction a reçu des arguments invalides.", 11 | "invalid_return_type": "Fonction a retourné un type invalide.", 12 | "invalid_date": "Date invalide.", 13 | "custom": "Champ invalide.", 14 | "invalid_intersection_types": "Les résultats d'intersection n'ont pas pu être fusionnés.", 15 | "not_multiple_of": "Ce champ doit être multiple de {{multipleOf}}.", 16 | "not_finite": "Ce champ doit être un nombre fini.", 17 | "invalid_string": { 18 | "email": "L'adresse e-mail que vous avez fournie est invalide.", 19 | "url": "Le lien que vous avez fournie est invalide.", 20 | "uuid": "L'UUID que vous avez fournie est invalide.", 21 | "cuid": "Le CUID que vous avez fournie est invalide.", 22 | "regex": "Le Regex que vous avez fournie est invalide.", 23 | "datetime": "La date que vous avez fournie est invalide.", 24 | "startsWith": "Ce champ est invalide: doit commencer par \"{{startsWith}}\".", 25 | "endsWith": "Ce champ est invalide: doit se terminer par \"{{endsWith}}\"." 26 | }, 27 | "too_small": { 28 | "array": { 29 | "exact": "Cette liste doit contenir exactement {{minimum}} élément(s).", 30 | "inclusive": "Cette liste doit contenir au moins {{minimum}} élément(s).", 31 | "not_inclusive": "Cette liste doit contenir plus de {{minimum}} élément(s)." 32 | }, 33 | "string": { 34 | "exact": "Ce champ doit contenir exactement {{minimum}} caractère(s).", 35 | "inclusive": "Ce champ doit contenir au moins {{minimum}} caractère(s).", 36 | "not_inclusive": "Ce champ doit centenir plus de {{minimum}} caractère(s)." 37 | }, 38 | "number": { 39 | "exact": "Nombre doit être égale à {{minimum}}.", 40 | "inclusive": "Nombre doit être supérieur ou égale à {{minimum}}.", 41 | "not_inclusive": "Nombre doit être supérieur à {{minimum}}." 42 | }, 43 | "set": { 44 | "exact": "Champ invalide.", 45 | "inclusive": "Champ invalide.", 46 | "not_inclusive": "Champ invalide." 47 | }, 48 | "date": { 49 | "exact": "La date doit être égale à {{- minimum, datetime}}.", 50 | "inclusive": "La date doit être supérieure ou égale à {{- minimum, datetime}}.", 51 | "not_inclusive": "La date doit être supérieure à {{- minimum, datetime}}." 52 | } 53 | }, 54 | "too_big": { 55 | "array": { 56 | "exact": "La liste doit contenir exactement {{maximum}} élément(s).", 57 | "inclusive": "La liste doit contenir au plus {{maximum}} élément(s).", 58 | "not_inclusive": "Liste doit contenir moins de {{maximum}} élément(s)." 59 | }, 60 | "string": { 61 | "exact": "Ce champ doit contenir exactement {{maximum}} caractère(s).", 62 | "inclusive": "Ce champ doit contenir au plus {{maximum}} caractère(s).", 63 | "not_inclusive": "Ce champ doit contenir moins de {{maximum}} caractère(s)." 64 | }, 65 | "number": { 66 | "exact": "Ce champ doit être égale à {{maximum}}.", 67 | "inclusive": "Ce champ doit être inférieur ou égale à {{maximum}}.", 68 | "not_inclusive": "Ce champ doit être inférieur à {{maximum}}." 69 | }, 70 | "set": { 71 | "exact": "Champ invalide", 72 | "inclusive": "Champ invalide", 73 | "not_inclusive": "Champ invalide" 74 | }, 75 | "date": { 76 | "exact": "La date doit être égale à {{- maximum, datetime}}.", 77 | "inclusive": "La date doit être inférieure ou égale à {{- maximum, datetime}}.", 78 | "not_inclusive": "La date doit être inférieure à {{- maximum, datetime}}." 79 | } 80 | } 81 | }, 82 | "validations": { 83 | "email": "e-mail", 84 | "url": "lien", 85 | "uuid": "UUID", 86 | "cuid": "CUID", 87 | "regex": "expression régulière", 88 | "datetime": "horodate" 89 | }, 90 | "types": { 91 | "function": "fonction", 92 | "number": "nombre", 93 | "string": "chaîne de caractères", 94 | "nan": "NaN", 95 | "integer": "entier", 96 | "float": "décimal", 97 | "boolean": "booléen", 98 | "date": "date", 99 | "bigint": "grand entier", 100 | "undefined": "undefined", 101 | "symbol": "symbole", 102 | "null": "null", 103 | "array": "liste", 104 | "object": "objet", 105 | "unknown": "inconnu", 106 | "promise": "promise", 107 | "void": "void", 108 | "never": "never", 109 | "map": "map", 110 | "set": "ensemble" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /backend/src/common/middlewares/exceptions.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import { HttpException } from "@/common/exceptions/http-exception"; 4 | import { ValidationException } from "@/common/exceptions/validation-exception"; 5 | 6 | const logger = new Logger("exceptions-middleware"); 7 | const isDev = process.env.NODE_ENV === "development"; 8 | 9 | /** 10 | * Global error handling middleware 11 | * 12 | * @param err - The Express error (can be ours or another) 13 | * @param req - The initial request 14 | * @param res - The response object 15 | * @param next - Allows passing to the next middleware if it exists 16 | * 17 | * @see https://expressjs.com/en/guide/error-handling.html 18 | */ 19 | export const exceptionsMiddleware = ( 20 | err: any, 21 | req: Request, 22 | res: Response, 23 | next: NextFunction 24 | ) => { 25 | if (res.headersSent) { 26 | return next(err); 27 | } 28 | 29 | if (err instanceof HttpException) { 30 | return res.status(err.status).json({ 31 | code: err.code, 32 | message: err.message, 33 | stack: isDev ? err.stack : undefined, 34 | cause: isDev && err.cause ? (err.cause as Error).message : undefined, 35 | }); 36 | } 37 | 38 | if (err instanceof ValidationException) { 39 | return res.status(err.status).json({ 40 | code: err.code, 41 | message: err.message, 42 | violations: err.violations, 43 | stack: isDev ? err.stack : undefined, 44 | cause: isDev && err.cause ? (err.cause as Error).message : undefined, 45 | }); 46 | } 47 | 48 | /** Log unhandled errors to a file */ 49 | logger.error("Unhandled error", { 50 | url: req.protocol + "://" + req.hostname + req.originalUrl, 51 | message: err.message, 52 | stack: err.stack, 53 | }); 54 | 55 | /** 56 | * In other cases, we return a 500 57 | */ 58 | return res.status(500).json({ message: err.message }); 59 | }; 60 | -------------------------------------------------------------------------------- /backend/src/common/middlewares/rewrite-ip-address.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from "express"; 2 | 3 | /** 4 | * Rewrite request's ip address with the real ip address received from cloudflare or other proxies. 5 | * If you need a library to get the real ip address @see: https://www.npmjs.com/package/request-ip 6 | */ 7 | export const rewriteIpAddressMiddleware = ( 8 | req: Request, 9 | res: Response, 10 | next: NextFunction 11 | ) => { 12 | // -- cloudflare 13 | const cfConnectingIp = req.headers["cf-connecting-ip"]; 14 | 15 | if (cfConnectingIp) { 16 | req.clientIp = cfConnectingIp as string; 17 | } 18 | 19 | next(); 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/common/middlewares/trim.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | 3 | // Define types for values that can be trimmed 4 | type TrimmableValue = string | TrimmableObject | TrimmableValue[]; 5 | interface TrimmableObject { 6 | [key: string]: TrimmableValue; 7 | } 8 | 9 | /** 10 | * Middleware that trims all string values in the request body 11 | * Handles nested objects, arrays, and primitive values 12 | */ 13 | export const trimMiddleware = ( 14 | req: Request, 15 | res: Response, 16 | next: NextFunction 17 | ): void => { 18 | if (req.body) { 19 | req.body = trimObject(req.body); 20 | } 21 | next(); 22 | }; 23 | 24 | /** 25 | * Trims a value based on its type 26 | * @param value - The value to trim (string, array, or object) 27 | * @returns The trimmed value 28 | */ 29 | const trimValue = (value: TrimmableValue): TrimmableValue => { 30 | if (typeof value === "string") { 31 | return value.trim(); 32 | } 33 | 34 | if (Array.isArray(value)) { 35 | return value.map(trimValue); 36 | } 37 | 38 | if (typeof value === "object" && value !== null) { 39 | return trimObject(value); 40 | } 41 | 42 | return value; 43 | }; 44 | 45 | /** 46 | * Recursively trims all string values in an object 47 | * @param obj - The object to process 48 | * @returns A new object with all string values trimmed 49 | */ 50 | const trimObject = (obj: T): T => { 51 | if (typeof obj !== "object" || obj === null) { 52 | return obj; 53 | } 54 | 55 | const trimmedObj = Object.entries(obj).reduce( 56 | (acc: Partial, [key, value]: [string, TrimmableValue]) => ({ 57 | ...acc, 58 | [key]: trimValue(value), 59 | }), 60 | {} 61 | ); 62 | 63 | return trimmedObj as T; 64 | }; 65 | -------------------------------------------------------------------------------- /backend/src/common/middlewares/unknown-routes.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request } from "express"; 2 | 3 | /** 4 | * For all other undefined routes, we return an error 5 | */ 6 | export const unknownRoutesMiddleware = (req: Request, res: Response) => { 7 | return res.status(404).json({ message: `This page does not exist.` }); 8 | }; 9 | -------------------------------------------------------------------------------- /backend/src/common/queue/app.worker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | This worker is a new process that will be executed in the background. 3 | This process can't access any variable from the main process or any other files. 4 | */ 5 | 6 | import { SandboxedJob } from "bullmq"; 7 | import { testingJob } from "./jobs/testing.job"; 8 | import { optimizeVideoJob } from "./jobs/optimize-video.job"; 9 | 10 | type JobHandler = (job: SandboxedJob) => Promise; 11 | 12 | const jobs: Record = { 13 | testingJob, 14 | optimizeVideoJob, 15 | }; 16 | 17 | export default async (job: SandboxedJob) => { 18 | const handler = jobs[job.name]; 19 | 20 | if (!handler) { 21 | throw new Error(`Unknown job type: ${job.name}`); 22 | } 23 | 24 | await handler(job); 25 | }; 26 | -------------------------------------------------------------------------------- /backend/src/common/queue/bullmq.ts: -------------------------------------------------------------------------------- 1 | import { Job, Worker } from "bullmq"; 2 | import { Logger } from "@/common/utils/logger"; 3 | 4 | type initWorkerEventsLoggerProps = { 5 | worker: Worker; 6 | }; 7 | 8 | const initWorkerEventsLogger = ({ worker }: initWorkerEventsLoggerProps) => { 9 | const logger = new Logger("bullmq"); 10 | 11 | // Start the time 12 | let startTime = Date.now(); 13 | 14 | worker.on("active", (job: Job) => { 15 | startTime = Date.now(); 16 | 17 | logger.debug(`Job #${job.id} started.`, { 18 | name: "job", 19 | jobName: job.name, 20 | jobId: job.id, 21 | }); 22 | }); 23 | 24 | worker.on("completed", (job: Job) => { 25 | // Calculate elapsed time 26 | const elapsedSeconds = (Date.now() - startTime) / 1000; 27 | const used = process.memoryUsage().heapUsed / 1024 / 1024; 28 | const memoryUsedInMB = Math.round(used * 100) / 100; 29 | 30 | // Write stats like elapsed time and memory used for this job to the logs 31 | logger.debug(`Job #${job.id} completed.`, { 32 | name: "job", 33 | jobName: job.name, 34 | jobId: job.id, 35 | elapsedTime: `${elapsedSeconds} seconds`, 36 | memoryUsed: `${memoryUsedInMB} MB`, 37 | }); 38 | }); 39 | 40 | worker.on("failed", (job: Job) => { 41 | logger.error(`Job #${job.id} failed.`, { 42 | name: "job", 43 | jobName: job.name, 44 | jobId: job.id, 45 | error: job.failedReason, 46 | }); 47 | }); 48 | }; 49 | 50 | export const bullmqService = { initWorkerEventsLogger }; 51 | -------------------------------------------------------------------------------- /backend/src/common/queue/jobs/optimize-video.job.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@/common/utils/logger"; 2 | import { prisma } from "@/common/database/prisma"; 3 | import { SandboxedJob } from "bullmq"; 4 | import path from "path"; 5 | import os from "os"; 6 | import crypto from "crypto"; 7 | import { s3Service } from "@/common/storage/s3"; 8 | import { FfmpegService } from "@/common/utils/ffmpeg"; 9 | 10 | export const optimizeVideoJob = async (job: SandboxedJob) => { 11 | const logger = new Logger("optimizeVideoJob"); 12 | const ffmpegService = new FfmpegService(); 13 | 14 | const { mediaId } = job.data; 15 | 16 | // -- get media 17 | const media = await prisma.media.findFirst({ 18 | where: { id: mediaId }, 19 | }); 20 | 21 | if (!media) { 22 | throw new Error(`Media ${mediaId} not found.`); 23 | } 24 | 25 | // -- download the video file from S3 26 | logger.info(`Downloading video file ${mediaId}...`); 27 | 28 | const tempVideoFilePath = path.join( 29 | os.tmpdir(), 30 | `${crypto.randomUUID()}_${media.fileName}` 31 | ); 32 | 33 | await s3Service.downloadToFile({ 34 | key: media.key, 35 | destinationPath: tempVideoFilePath, 36 | }); 37 | 38 | // -- optimize the video file with ffmpeg 39 | logger.info(`Optimizing video file ${mediaId}...`); 40 | 41 | const fileNameMp4 = media.fileName.replace(/\.[^.]+$/, ".mp4"); 42 | const destVideoFilePath = path.join( 43 | os.tmpdir(), 44 | `${crypto.randomUUID()}_${fileNameMp4}` 45 | ); 46 | 47 | await ffmpegService.processVideoEncoding({ 48 | inputFilePath: tempVideoFilePath, 49 | outputFilePath: destVideoFilePath, 50 | }); 51 | 52 | // -- upload the optimized video file to S3 53 | logger.info(`Uploading optimized video file ${mediaId}...`); 54 | 55 | const newKey = await s3Service.upload({ 56 | filePath: destVideoFilePath, 57 | fileName: fileNameMp4, 58 | mimeType: "video/mp4", 59 | }); 60 | 61 | // -- update the media record with the new file key 62 | await prisma.media.update({ 63 | where: { id: mediaId }, 64 | data: { key: newKey, mimeType: "video/mp4", fileName: fileNameMp4 }, 65 | }); 66 | 67 | // -- delete the previous file from S3 68 | await s3Service.deleteFile({ key: media.key }); 69 | 70 | logger.info( 71 | `Optimized video file ${mediaId} uploaded to S3 as ${newKey} successfully.` 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /backend/src/common/queue/jobs/testing.job.ts: -------------------------------------------------------------------------------- 1 | import { SandboxedJob } from "bullmq"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import { prisma } from "@/common/database/prisma"; 4 | 5 | export const testingJob = async (job: SandboxedJob) => { 6 | const logger = new Logger("testingJob"); 7 | 8 | const { message } = job.data; 9 | 10 | logger.info(`Received message: ${message}`); 11 | logger.info("Hello World from testing job"); 12 | 13 | // try prisma 14 | const accounts = await prisma.account.findMany({}); 15 | logger.debug("Accounts list received", { 16 | name: "testingWorker", // optional 17 | accounts, 18 | }); 19 | 20 | // Can throw an error 21 | // throw new Error("Testing error"); 22 | }; 23 | -------------------------------------------------------------------------------- /backend/src/common/queue/queue.service.ts: -------------------------------------------------------------------------------- 1 | import { REDIS_CONNECTION } from "@/common/services/redis.service"; 2 | import { bullmqService } from "@/common/queue/bullmq"; 3 | import { Queue, Worker } from "bullmq"; 4 | import path from "path"; 5 | 6 | class QueueService { 7 | private queue: Queue; 8 | private worker: Worker; 9 | 10 | constructor() { 11 | this.queue = new Queue("app", { 12 | connection: REDIS_CONNECTION, 13 | }); 14 | 15 | this.worker = new Worker( 16 | "app", // queue name 17 | path.join(__dirname, "app.worker.js"), 18 | { 19 | connection: REDIS_CONNECTION, 20 | removeOnComplete: { count: 10 }, 21 | } 22 | ); 23 | 24 | // -- Worker Events 25 | bullmqService.initWorkerEventsLogger({ 26 | worker: this.worker, 27 | }); 28 | } 29 | 30 | public close = async () => { 31 | await this.worker.close(); 32 | await this.worker.disconnect(); 33 | 34 | await this.queue.close(); 35 | await this.queue.disconnect(); 36 | }; 37 | 38 | public addTestingJob = (message: string) => { 39 | this.queue.add("testingJob", { message }); 40 | }; 41 | 42 | public addOptimizeVideoJob = (mediaId: string) => { 43 | this.queue.add("optimizeVideoJob", { mediaId }); 44 | }; 45 | } 46 | 47 | export const queueService = new QueueService(); 48 | -------------------------------------------------------------------------------- /backend/src/common/services/app.service.ts: -------------------------------------------------------------------------------- 1 | import { queueService } from "@/common/queue/queue.service"; 2 | import { redisService } from "@/common/services/redis.service"; 3 | import { prisma } from "@/common/database/prisma"; 4 | 5 | class AppService { 6 | cleanup = async () => { 7 | // Close all queue connections 8 | await queueService.close(); 9 | 10 | // Close Redis connection 11 | await redisService.quit(); 12 | 13 | // Close Prisma connection 14 | await prisma.$disconnect(); 15 | }; 16 | 17 | shutdownGracefully = async () => { 18 | try { 19 | await this.cleanup(); 20 | process.exit(0); 21 | } catch (error) { 22 | console.error("Error during shutdown:", error); 23 | process.exit(1); 24 | } 25 | }; 26 | } 27 | 28 | export const appService = new AppService(); 29 | -------------------------------------------------------------------------------- /backend/src/common/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { authConfig } from "@/modules/auth/auth.config"; 3 | import { prisma } from "../database/prisma"; 4 | import crypto from "crypto"; 5 | import bcrypt from "bcrypt"; 6 | import { env } from "@/config"; 7 | import { dateUtils } from "../utils/date"; 8 | import { Role } from "@/generated/prisma/client"; 9 | 10 | class AuthService { 11 | signJwt = ({ 12 | payload, 13 | options, 14 | }: { 15 | payload: string | Buffer | object; 16 | options: jwt.SignOptions; 17 | }): Promise => { 18 | return new Promise((resolve, reject) => { 19 | jwt.sign( 20 | payload, 21 | env.APP_JWT_SECRET_KEY, 22 | { 23 | algorithm: "RS256", // Recommended algorithm for JWT (Asymmetric, uses a private key to sign and a public key to verify.). The default one is HS256 (Symmetric, uses a single secret key for both signing and verifying). 24 | ...options, 25 | }, 26 | (err, token) => { 27 | if (err) { 28 | reject(err); 29 | } else { 30 | resolve(token as string); 31 | } 32 | } 33 | ); 34 | }); 35 | }; 36 | 37 | getJwtPayload = (token: string): Promise<{ sub: string; role: Role }> => { 38 | return new Promise((resolve, reject) => { 39 | jwt.verify(token, env.APP_JWT_PUBLIC_KEY, (err, payload) => { 40 | if (err) { 41 | reject(err); 42 | } else { 43 | resolve(payload as { sub: string; role: Role }); 44 | } 45 | }); 46 | }); 47 | }; 48 | 49 | /** 50 | * Method to generate a secure unique token 51 | */ 52 | generateUniqueToken = ({ length = 32 }: { length?: number } = {}) => { 53 | const result = crypto.randomBytes(length); 54 | return result.toString("hex"); 55 | }; 56 | 57 | comparePassword = async ({ 58 | password, 59 | hashedPassword, 60 | }: { 61 | password: string; 62 | hashedPassword: string; 63 | }) => { 64 | return await bcrypt.compare(password, hashedPassword); 65 | }; 66 | 67 | hashPassword = async ({ password }: { password: string }) => { 68 | return await bcrypt.hash(password, 10); 69 | }; 70 | 71 | /** 72 | * Generate a JWT access token for a given account id. 73 | */ 74 | generateAuthenticationTokens = async ({ 75 | accountId, 76 | }: { 77 | accountId: string; 78 | }): Promise<{ accessToken: string; refreshToken: string }> => { 79 | // Get the user 80 | const account = await prisma.account.findUnique({ 81 | where: { id: accountId }, 82 | }); 83 | 84 | if (!account) { 85 | throw new Error("Account does not exist."); 86 | } 87 | 88 | // Create a new session 89 | const session = await prisma.session.create({ 90 | data: { 91 | expiresAt: dateUtils.add(new Date(), { 92 | minutes: authConfig.refreshTokenExpirationMinutes, 93 | }), 94 | accountId, 95 | }, 96 | }); 97 | 98 | // Generate the JWT access token 99 | const accessToken = await this.signJwt({ 100 | payload: { 101 | sub: session.id, 102 | role: account.role, 103 | }, 104 | options: { 105 | expiresIn: `${authConfig.accessTokenExpirationMinutes}m`, 106 | }, 107 | }); 108 | 109 | // Generate the JWT refresh token 110 | const refreshToken = await this.signJwt({ 111 | payload: { 112 | sub: session.id, 113 | }, 114 | options: { 115 | expiresIn: `${authConfig.refreshTokenExpirationMinutes}m`, 116 | }, 117 | }); 118 | 119 | return { 120 | accessToken, 121 | refreshToken, 122 | }; 123 | }; 124 | 125 | refreshAuthenticationTokens = async ({ 126 | refreshToken, 127 | }: { 128 | refreshToken: string; 129 | }) => { 130 | const payload = await this.getJwtPayload(refreshToken).catch(() => { 131 | throw new Error("Invalid or expired refresh token."); 132 | }); 133 | 134 | if (!payload) { 135 | throw new Error("Invalid or expired refresh token."); 136 | } 137 | 138 | const session = await prisma.session.findUnique({ 139 | include: { 140 | account: true, 141 | }, 142 | where: { 143 | id: payload.sub, 144 | expiresAt: { 145 | gt: new Date(), 146 | }, 147 | }, 148 | }); 149 | 150 | if (!session) { 151 | throw new Error("This session does not exist."); 152 | } 153 | 154 | // Generate the JWT access token 155 | const accessToken = await this.signJwt({ 156 | payload: { 157 | sub: session.id, 158 | role: session.account.role, 159 | }, 160 | options: { 161 | expiresIn: `${authConfig.accessTokenExpirationMinutes}m`, 162 | }, 163 | }); 164 | 165 | // Generate the JWT refresh token 166 | const newRefreshToken = await this.signJwt({ 167 | payload: { 168 | sub: session.id, 169 | }, 170 | options: { 171 | expiresIn: `${authConfig.refreshTokenExpirationMinutes}m`, 172 | }, 173 | }); 174 | 175 | // Update the expiration date of the session 176 | await prisma.session.update({ 177 | where: { id: session.id }, 178 | data: { 179 | expiresAt: dateUtils.add(new Date(), { 180 | minutes: authConfig.refreshTokenExpirationMinutes, 181 | }), 182 | }, 183 | }); 184 | 185 | return { 186 | accessToken, 187 | refreshToken: newRefreshToken, 188 | }; 189 | }; 190 | } 191 | 192 | export const authService = new AuthService(); 193 | -------------------------------------------------------------------------------- /backend/src/common/services/files.service.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import FileType from "file-type"; // version 16.5.4 4 | import crypto from "crypto"; 5 | 6 | const getFileInfos = async (filePath: string) => { 7 | try { 8 | const stats = fs.statSync(filePath); 9 | const fileType = await FileType.fromFile(filePath); 10 | 11 | return { 12 | filename: path.basename(filePath), 13 | path: filePath, 14 | size: stats.size, 15 | mimeType: fileType ? fileType.mime : "application/octet-stream", 16 | }; 17 | } catch (error) { 18 | console.error("Error getting file details:", error); 19 | throw error; 20 | } 21 | }; 22 | 23 | const getNormalizedFileName = (filename: string, appendRandom = true) => { 24 | // Remove special characters using path.normalize() 25 | const normalized = path.normalize(filename); 26 | 27 | const extension = path.extname(normalized); 28 | const baseName = path.basename(normalized, extension); 29 | 30 | let finalName: string; 31 | 32 | if (appendRandom) { 33 | // -- Add random number before the file extension 34 | // Generate a secure random string with 16 bytes (it's impossible to have a collision even with 100000000 billions of generated values) 35 | const random = crypto.randomBytes(16).toString("hex"); 36 | 37 | finalName = `${baseName}-${random}${extension}`; 38 | } else { 39 | finalName = `${baseName}${extension}`; 40 | } 41 | 42 | // Remove whitespace and other characters using a regular expression 43 | const cleaned = finalName.replace(/[^a-zA-Z0-9.]+/g, "_"); 44 | 45 | // Join directory and normalized filename components back together 46 | const normalizedFilename = path.join(path.dirname(normalized), cleaned); 47 | 48 | return normalizedFilename; 49 | }; 50 | 51 | export const filesService = { getFileInfos, getNormalizedFileName }; 52 | -------------------------------------------------------------------------------- /backend/src/common/services/pdf.service.ts: -------------------------------------------------------------------------------- 1 | import { Browser, BrowserContext, chromium, devices } from "playwright"; 2 | 3 | class PdfService { 4 | private browser: Browser; 5 | private context: BrowserContext; 6 | 7 | /** 8 | * Get the browser instance 9 | * If the browser is not initialized, it will create a new one 10 | */ 11 | getBrowser = async () => { 12 | if (!this.browser) { 13 | this.browser = await chromium.launch({ 14 | headless: true, // If true, hide the browser, if false, show the browser 15 | }); 16 | } 17 | 18 | return this.browser; 19 | }; 20 | 21 | /** 22 | * Get the context instance 23 | * If the context is not initialized, it will create a new one. 24 | * If the browser is not initialized yet, it will create a new one too. 25 | * You can use the context to create pages. 26 | */ 27 | getContext = async () => { 28 | if (!this.context) { 29 | const browser = await this.getBrowser(); 30 | this.context = await browser.newContext({ 31 | ...devices["Desktop Chrome"], 32 | viewport: { 33 | width: 1920, 34 | height: 1080, 35 | }, 36 | }); 37 | } 38 | 39 | return this.context; 40 | }; 41 | 42 | /** 43 | * Generate a PDF from an HTML string 44 | * @param html - The HTML string to generate a PDF from 45 | */ 46 | htmlToPdf = async ({ html }: { html: string }) => { 47 | // Launch browser 48 | const context = await this.getContext(); 49 | const page = await context.newPage(); 50 | 51 | // Uncomment to see the console logs from the page 52 | // context.on("console", (msg) => console.log("PAGE LOG:", msg.text())); 53 | 54 | // Set content and wait for loading 55 | await page.setContent(html, { waitUntil: "load" }); 56 | 57 | // Generate PDF 58 | const pdfBuffer = await page.pdf({ 59 | format: "A4", 60 | margin: { 61 | top: "20px", 62 | right: "20px", 63 | bottom: "20px", 64 | left: "20px", 65 | }, 66 | printBackground: true, 67 | }); 68 | 69 | // Close page 70 | await page.close(); 71 | 72 | return pdfBuffer; 73 | }; 74 | } 75 | 76 | export const pdfService = new PdfService(); 77 | -------------------------------------------------------------------------------- /backend/src/common/services/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "ioredis"; 2 | import { Logger } from "@/common/utils/logger"; 3 | import { env } from "@/config"; 4 | const logger = new Logger("redis"); 5 | 6 | export const REDIS_CONNECTION = { 7 | host: env.APP_REDIS_HOST, 8 | port: env.APP_REDIS_PORT, 9 | }; 10 | 11 | class RedisService { 12 | private readonly client: Redis; 13 | 14 | constructor() { 15 | this.client = new Redis({ 16 | ...REDIS_CONNECTION, 17 | }); 18 | 19 | this.client.on("error", (err) => { 20 | logger.error("Redis error occured.", { error: err }); 21 | }); 22 | } 23 | 24 | /** 25 | * Get a value from the cache. (JSON supported) 26 | * 27 | * @param key 28 | * @returns 29 | */ 30 | get = async (key: string) => { 31 | const value = await this.client.get(key); 32 | return value ? JSON.parse(value) : null; 33 | }; 34 | 35 | /** 36 | * Set a value in the cache with an optional expiration time. (JSON supported) 37 | * 38 | * @param key - The key to store the value under 39 | * @param value - The value to store 40 | * @param ttl - Time to live in seconds (optional) 41 | */ 42 | set = async ( 43 | key: string, 44 | value: string | number | boolean | object, 45 | ttl?: number 46 | ) => { 47 | if (ttl) { 48 | await this.client.set(key, JSON.stringify(value), "EX", ttl); 49 | } else { 50 | await this.client.set(key, JSON.stringify(value)); 51 | } 52 | }; 53 | 54 | /** 55 | * Delete a key from the cache. 56 | * 57 | * @param key 58 | */ 59 | delete = async (key: string) => { 60 | await this.client.del(key); 61 | }; 62 | 63 | /** 64 | * Flush all keys from the cache. 65 | */ 66 | flushall = async () => { 67 | await this.client.flushall(); 68 | }; 69 | 70 | /** 71 | * Close the Redis connection. 72 | */ 73 | quit = async () => { 74 | await this.client.quit(); 75 | }; 76 | } 77 | 78 | export const redisService = new RedisService(); 79 | -------------------------------------------------------------------------------- /backend/src/common/storage/s3.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | GetObjectCommand, 4 | PutObjectCommand, 5 | S3Client, 6 | StorageClass, 7 | } from "@aws-sdk/client-s3"; 8 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 9 | import fs from "fs"; 10 | import path from "path"; 11 | import { filesService } from "@/common/services/files.service"; 12 | import { dateUtils } from "@/common/utils/date"; 13 | import { HttpException } from "@/common/exceptions/http-exception"; 14 | import { env } from "@/config"; 15 | class S3Service { 16 | private client = new S3Client({ 17 | endpoint: env.API_S3_ENDPOINT, 18 | region: "auto", // [ex for AWS: eu-west-3] [ex for Cloudflare: auto] 19 | credentials: { 20 | accessKeyId: env.API_S3_ACCESS_KEY, 21 | secretAccessKey: env.API_S3_SECRET_KEY, 22 | }, 23 | }); 24 | 25 | private bucketName = env.API_S3_BUCKET; 26 | 27 | /** 28 | * Save a file to S3 bucket and create a record in the database 29 | * 30 | * @param filePath The path to the file 31 | * @param fileName The name of the file to store in S3 32 | * @param mimeType The MIME type of the file 33 | * @param storageClass The storage class of the file in S3. [STANDARD_IA] (Standard Infrequent Access) is 2x cheaper than STANDARD for still good performance || [STANDARD] is the default for frequent access 34 | * 35 | * @returns The key in S3 36 | */ 37 | upload = async ({ 38 | filePath, 39 | fileName, 40 | mimeType, 41 | storageClass = "STANDARD", 42 | }: { 43 | filePath: string; 44 | fileName: string; 45 | mimeType: string; 46 | storageClass?: StorageClass; 47 | }) => { 48 | const buffer = fs.readFileSync(filePath); 49 | const normalizedFileName = filesService.getNormalizedFileName(fileName); 50 | 51 | const key = path.join( 52 | dateUtils.format(new Date(), "yyyy"), 53 | dateUtils.format(new Date(), "MM"), 54 | dateUtils.format(new Date(), "dd"), 55 | normalizedFileName 56 | ); 57 | 58 | // -- Save to S3 59 | await this.client.send( 60 | new PutObjectCommand({ 61 | Bucket: this.bucketName, 62 | Key: key, 63 | Body: buffer, 64 | StorageClass: storageClass, 65 | ContentType: mimeType, 66 | }) 67 | ); 68 | 69 | return key; 70 | }; 71 | 72 | /** 73 | * Download a file from S3 to /tmp directory and return the path 74 | * @param params.key The key of the file in S3 75 | * @param params.destinationPath The path to save the downloaded file 76 | * @returns The path to the downloaded file 77 | */ 78 | downloadToFile = async ({ 79 | key, 80 | destinationPath, 81 | }: { 82 | key: string; 83 | destinationPath?: string; 84 | }) => { 85 | if (!destinationPath) { 86 | destinationPath = path.join("/tmp", path.basename(key)); 87 | } 88 | 89 | const data = await this.client.send( 90 | new GetObjectCommand({ 91 | Bucket: this.bucketName, 92 | Key: key, 93 | }) 94 | ); 95 | 96 | if (!data.Body) { 97 | throw HttpException.notFound({ 98 | message: "File not found.", 99 | }); 100 | } 101 | 102 | const fileWriteStream = fs.createWriteStream(destinationPath); 103 | 104 | const stream = new WritableStream({ 105 | write(chunk) { 106 | fileWriteStream.write(chunk); 107 | }, 108 | close() { 109 | fileWriteStream.close(); 110 | }, 111 | abort(err) { 112 | fileWriteStream.destroy(err); 113 | throw err; 114 | }, 115 | }); 116 | 117 | // You cannot await just the pipeTo() because you must wait for 118 | // both pipeTo AND createWriteStream to finish. 119 | await new Promise((resolve, reject) => { 120 | fileWriteStream.on("finish", resolve); 121 | fileWriteStream.on("error", reject); 122 | data.Body?.transformToWebStream().pipeTo(stream); 123 | }); 124 | 125 | return destinationPath; 126 | }; 127 | 128 | /** 129 | * Download a file from S3 to memory and return the base64 string 130 | */ 131 | downloadToMemoryBase64 = async ({ key }: { key: string }) => { 132 | const data = await this.client.send( 133 | new GetObjectCommand({ 134 | Bucket: this.bucketName, 135 | Key: key, 136 | }) 137 | ); 138 | 139 | if (!data.Body) { 140 | throw new HttpException({ 141 | status: 404, 142 | message: "File not found.", 143 | }); 144 | } 145 | 146 | const contentType = data.ContentType; 147 | const streamToString = await data.Body.transformToString("base64"); 148 | 149 | return { 150 | contentType, 151 | base64: streamToString, 152 | }; 153 | }; 154 | 155 | /** 156 | * Delete a file from S3 bucket 157 | */ 158 | deleteFile = async ({ key }: { key: string }) => { 159 | await this.client.send( 160 | new DeleteObjectCommand({ 161 | Bucket: this.bucketName, 162 | Key: key, 163 | }) 164 | ); 165 | }; 166 | 167 | /** 168 | * Get a presigned URL to upload a file to a S3 bucket 169 | */ 170 | getPresignedUploadUrl = async ({ 171 | key, 172 | storageClass = "STANDARD", 173 | }: { 174 | key: string; 175 | storageClass?: StorageClass; 176 | }) => { 177 | const presignedUrl = await getSignedUrl( 178 | this.client, 179 | new PutObjectCommand({ 180 | Bucket: this.bucketName, 181 | Key: key, 182 | StorageClass: storageClass, 183 | }), 184 | { expiresIn: 3600 } // 1 hour 185 | ); 186 | 187 | return presignedUrl; 188 | }; 189 | 190 | getPresignedUrl = async ({ 191 | key, 192 | expiresIn = 3600, 193 | }: { 194 | key: string; 195 | expiresIn?: number; 196 | }) => { 197 | return await getSignedUrl( 198 | this.client, 199 | new GetObjectCommand({ Bucket: this.bucketName, Key: key }), 200 | { expiresIn } 201 | ); 202 | }; 203 | } 204 | 205 | export const s3Service = new S3Service(); 206 | -------------------------------------------------------------------------------- /backend/src/common/test/jest-initialize-db.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../database/prisma"; 2 | import { authService } from "../services/auth.service"; 3 | 4 | export const initializeTestDb = async () => { 5 | await cleanupTestDb(); 6 | 7 | await seedTestDb(); 8 | }; 9 | 10 | const cleanupTestDb = async () => { 11 | // Delete in reverse order of dependencies to avoid foreign key conflicts 12 | const tables = [ 13 | // Customers 14 | "Customer", 15 | 16 | // Admins 17 | "Admin", 18 | 19 | // Sessions (should be the last) 20 | "Session", 21 | "Account", 22 | ]; 23 | 24 | // Delete all tables in a transaction 25 | await prisma.$transaction(tables.map((table) => prisma[table].deleteMany())); 26 | }; 27 | 28 | export const seedCustomerId = "dada5771-b561-4dd6-9118-13543ae35169"; 29 | export const seedAdminId = "826b57af-f17b-4eb8-9302-a743b1b1e707"; 30 | 31 | const seedAdmin = async () => { 32 | const hashedPassword = await authService.hashPassword({ 33 | password: "password", 34 | }); 35 | 36 | await prisma.admin.create({ 37 | data: { 38 | id: seedAdminId, 39 | email: "contact@lunisoft.fr", 40 | password: hashedPassword, 41 | account: { 42 | create: { 43 | role: "ADMIN", 44 | }, 45 | }, 46 | }, 47 | }); 48 | }; 49 | 50 | const seedCustomer = async () => { 51 | const hashedPassword = await authService.hashPassword({ 52 | password: "password", 53 | }); 54 | 55 | // Seed Customer 56 | await prisma.customer.create({ 57 | data: { 58 | id: seedCustomerId, 59 | email: "customer@lunisoft.fr", 60 | password: hashedPassword, 61 | account: { 62 | create: { 63 | role: "CUSTOMER", 64 | }, 65 | }, 66 | }, 67 | }); 68 | }; 69 | 70 | const seedTestDb = async () => { 71 | // Seed Admin 72 | await seedAdmin(); 73 | 74 | // Seed Customer 75 | await seedCustomer(); 76 | }; 77 | -------------------------------------------------------------------------------- /backend/src/common/test/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import { appService } from "@/common/services/app.service"; 2 | 3 | afterAll(async () => { 4 | // Close all connections 5 | await appService.cleanup(); 6 | }); 7 | -------------------------------------------------------------------------------- /backend/src/common/test/jest-utils.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../database/prisma"; 2 | import { authService } from "../services/auth.service"; 3 | import { seedAdminId, seedCustomerId } from "./jest-initialize-db"; 4 | 5 | /** 6 | * Get the admin user access token 7 | * We will need it for some tests that require authentication and role 8 | * @returns The admin user access token 9 | */ 10 | export const getAdminUserAccessToken = async () => { 11 | const adminAccount = await prisma.account.findFirst({ 12 | where: { 13 | admin: { 14 | id: seedAdminId, 15 | }, 16 | }, 17 | }); 18 | 19 | if (!adminAccount) { 20 | throw new Error("Admin account not found"); 21 | } 22 | 23 | const { accessToken } = await authService.generateAuthenticationTokens({ 24 | accountId: adminAccount.id, 25 | }); 26 | 27 | return accessToken; 28 | }; 29 | 30 | /** 31 | * Get the customer user access token 32 | * We will need it for some tests that require authentication and role 33 | * @returns The customer user access token 34 | */ 35 | export const getCustomerUserAccessToken = async () => { 36 | const customerAccount = await prisma.account.findFirst({ 37 | where: { 38 | customer: { id: seedCustomerId }, 39 | }, 40 | }); 41 | 42 | if (!customerAccount) { 43 | throw new Error("Customer account not found"); 44 | } 45 | 46 | const { accessToken } = await authService.generateAuthenticationTokens({ 47 | accountId: customerAccount.id, 48 | }); 49 | 50 | return accessToken; 51 | }; 52 | -------------------------------------------------------------------------------- /backend/src/common/throttlers/global.throttler.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from "express-rate-limit"; 2 | import { Request } from "express"; 3 | 4 | export const globalThrottler = rateLimit({ 5 | windowMs: 1 * 60 * 1000, // 1 minutes 6 | max: 500, // Limit each IP to X requests per `windowMs` 7 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 8 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 9 | keyGenerator: (req: Request) => { 10 | // Use the ip address given by the proxy 11 | return req.clientIp || req.ip || req.socket?.remoteAddress || "anonymous"; 12 | }, 13 | skip: () => { 14 | // -- Skip rate limiting in test mode 15 | if (process.env.NODE_ENV === "test") { 16 | return true; 17 | } 18 | 19 | return false; 20 | }, 21 | message: { message: "Too many requests, please try again later." }, 22 | }); 23 | -------------------------------------------------------------------------------- /backend/src/common/throttlers/strict.throttler.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from "express-rate-limit"; 2 | import { Request } from "express"; 3 | 4 | export const strictThrottler = rateLimit({ 5 | windowMs: 1 * 60 * 1000, // 1 minutes 6 | max: 10, // Limit each IP to X requests per `windowMs` 7 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 8 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 9 | keyGenerator: (req: Request) => { 10 | // Use the ip address given by the proxy 11 | return req.clientIp || req.ip || req.socket?.remoteAddress || "anonymous"; 12 | }, 13 | skip: (req: Request) => { 14 | // -- Skip rate limiting in test mode 15 | if ( 16 | process.env.NODE_ENV === "test" && 17 | req.headers["x-throttler-test-mode"] 18 | ) { 19 | return true; 20 | } 21 | 22 | return false; 23 | }, 24 | message: { message: "Too many requests, please try again later." }, 25 | }); 26 | -------------------------------------------------------------------------------- /backend/src/common/throttlers/usage.throttler.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from "express-rate-limit"; 2 | import { Request } from "express"; 3 | 4 | export const usageThrottler = rateLimit({ 5 | windowMs: 1 * 60 * 1000, // 1 minutes 6 | max: 5, // Limit each IP to X requests per `windowMs` 7 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 8 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 9 | keyGenerator: (req) => { 10 | // Use both IP address and user email as the key 11 | const ip = req.clientIp; 12 | let email = "unknown"; 13 | 14 | if (req.context?.account?.customer?.email) { 15 | email = req.context.account.customer.email; 16 | } 17 | 18 | return `${ip}-${email}`; 19 | }, 20 | skip: (req: Request) => { 21 | // -- Skip rate limiting in test mode 22 | if ( 23 | process.env.NODE_ENV === "test" && 24 | req.headers["x-throttler-test-mode"] 25 | ) { 26 | return true; 27 | } 28 | 29 | return false; 30 | }, 31 | message: { 32 | message: 33 | "You have reached the maximum number of requests for your plan. Please try again later or contact support if you believe this is an error.", 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /backend/src/common/transformers/empty-string-to-null.transformer.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const emptyStringToNullTransformer = z 4 | .string() 5 | .transform((v) => (v === "" ? null : v)); 6 | -------------------------------------------------------------------------------- /backend/src/common/transformers/phone-number.transformer.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { phoneUtils } from "@/common/utils/phone-utils"; 3 | 4 | export const phoneNumberTransformer = z 5 | .string() 6 | .transform((value) => 7 | phoneUtils.parse({ phoneNumber: value }).formatInternational() 8 | ); 9 | -------------------------------------------------------------------------------- /backend/src/common/types/request.d.ts: -------------------------------------------------------------------------------- 1 | import { Account, Admin, Customer } from "@/generated/prisma/client"; 2 | 3 | export type CustomAccount = Account & { 4 | admin: Admin; 5 | customer: Customer; 6 | }; 7 | 8 | export type Context = { 9 | account: CustomAccount | undefined; 10 | }; 11 | 12 | declare global { 13 | namespace Express { 14 | interface Request { 15 | // Add context to the request 16 | context?: Context | undefined; 17 | 18 | // Get the real ip address from cloudflare or other proxies 19 | clientIp?: string; 20 | } 21 | } 22 | } 23 | 24 | export {}; 25 | -------------------------------------------------------------------------------- /backend/src/common/utils/app-dir.ts: -------------------------------------------------------------------------------- 1 | // more infos: https://stackoverflow.com/questions/10265798/determine-project-root-from-a-running-node-js-application 2 | 3 | import path from "path"; 4 | import fs from "fs"; 5 | 6 | let memoizedAppDir: string | null = null; 7 | let memoizedRootDir: string | null = null; 8 | 9 | /** 10 | * Get the root directory of the application (where the app.ts file is located). 11 | * On your app.ts file, add this line: 12 | * ```ts 13 | * import path from "path"; 14 | * global.appRoot = path.resolve(__dirname); 15 | * ``` 16 | */ 17 | export const getAppDir = () => { 18 | // Return memoized value if available 19 | if (memoizedAppDir) { 20 | return memoizedAppDir; 21 | } 22 | 23 | // Start from the current directory and traverse up until we find app.ts or app.js 24 | let currentDir = __dirname; 25 | while (currentDir !== path.parse(currentDir).root) { 26 | const appTsPath = path.join(currentDir, "app.ts"); 27 | const appJsPath = path.join(currentDir, "app.js"); 28 | 29 | if (fs.existsSync(appTsPath) || fs.existsSync(appJsPath)) { 30 | // Found the directory containing app.ts or app.js 31 | global.appRoot = currentDir; 32 | memoizedAppDir = currentDir; 33 | return currentDir; 34 | } 35 | 36 | // Move up one directory 37 | currentDir = path.dirname(currentDir); 38 | } 39 | 40 | // Fallback to error if no app.ts or app.js is found 41 | throw new Error("No app.ts or app.js found in the project"); 42 | }; 43 | 44 | /** 45 | * Get the root directory of the application (where the package.json file is located). 46 | * @returns 47 | */ 48 | export const getRootDir = () => { 49 | // Return memoized value if available 50 | if (memoizedRootDir) { 51 | return memoizedRootDir; 52 | } 53 | 54 | // Start from the current directory and traverse up until we find package.json 55 | let currentDir = __dirname; 56 | while (currentDir !== path.parse(currentDir).root) { 57 | const packageJsonPath = path.join(currentDir, "package.json"); 58 | 59 | if (fs.existsSync(packageJsonPath)) { 60 | // Found the directory containing package.json 61 | memoizedRootDir = currentDir; 62 | return currentDir; 63 | } 64 | 65 | // Move up one directory 66 | currentDir = path.dirname(currentDir); 67 | } 68 | 69 | // Fallback to error if no package.json is found 70 | throw new Error("No package.json found in the project"); 71 | }; 72 | -------------------------------------------------------------------------------- /backend/src/common/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NearestMinutes, 3 | add, 4 | format, 5 | isAfter, 6 | isBefore, 7 | isSameHour, 8 | isSameMinute, 9 | isSunday, 10 | isSameMonth, 11 | isValid, 12 | parse, 13 | parseISO, 14 | roundToNearestMinutes, 15 | sub, 16 | startOfDay, 17 | endOfDay, 18 | intervalToDuration, 19 | differenceInMinutes, 20 | startOfMonth, 21 | lastDayOfMonth, 22 | endOfMonth, 23 | } from "date-fns"; 24 | import { setDefaultOptions } from "date-fns"; 25 | import { fr } from "date-fns/locale"; 26 | setDefaultOptions({ locale: fr }); 27 | 28 | /** 29 | * Convert minutes to hours as human readable (ex: 143 minutes => 2h23) 30 | * 31 | * @param minutes 32 | * @returns 33 | */ 34 | const minutesToHoursHr = (minutes) => { 35 | // convert minutes to hours 36 | const hours = Math.floor(minutes / 60); 37 | // get remaining time 38 | const minRemaining = minutes % 60; 39 | 40 | return `${hours}h${minRemaining ? minRemaining : ""}`; 41 | }; 42 | 43 | /** 44 | * Generate intervals between startTime and endTime to nearest minutes (each 30 minutes) 45 | * 46 | * @param startTime 47 | * @param endTime 48 | * @param nearestTo Nearest minutes (ex: each 15 minutes) 49 | * @example [ "08:00", "08:30", "09:00", "09:30", "10:00", "10:30" ] 50 | */ 51 | const eachTimeOfInterval = ( 52 | startTime: Date, 53 | endTime: Date, 54 | nearestTo: NearestMinutes = 30 55 | ): string[] => { 56 | const output: string[] = []; 57 | let start = startTime; 58 | const end = endTime; 59 | 60 | // -------------------------------- 61 | // add the first interval 62 | // -------------------------------- 63 | output.push(start.toISOString()); 64 | 65 | // -------------------------------- 66 | // add the other intervals 67 | // -------------------------------- 68 | while ( 69 | isBefore(start, end) || 70 | (isSameHour(start, end) && isSameMinute(start, end)) 71 | ) { 72 | // round the start time to the nearest quarter minutes 73 | const roundedToQuarterMinutes = roundToNearestMinutes(start, { 74 | nearestTo: nearestTo, 75 | }); 76 | 77 | // verify if the interval is not already in the output 78 | if (output.includes(roundedToQuarterMinutes.toISOString()) === false) { 79 | // add the interval to the output 80 | output.push(roundedToQuarterMinutes.toISOString()); 81 | } 82 | 83 | // increment the start time 84 | start = add(roundedToQuarterMinutes, { minutes: nearestTo }); 85 | } 86 | 87 | return output; 88 | }; 89 | 90 | /** 91 | * Count number of business hours between two dates 92 | */ 93 | const countWeekdayMinutes = ({ startDate, endDate }) => { 94 | let weekdayMinutes = 0; 95 | let actualDate = startDate; 96 | 97 | while (isBefore(actualDate, endDate)) { 98 | if (!isSunday(actualDate)) { 99 | const hourOfDay = actualDate.getHours(); 100 | 101 | // In UTC time, 4h (UTC) is 5h in Paris (UTC+1) 102 | // In UTC time, 22h (UTC) is 23h in Paris (UTC+1) 103 | if (hourOfDay >= 4 && hourOfDay < 22) { 104 | weekdayMinutes++; 105 | } 106 | } 107 | 108 | actualDate = add(actualDate, { minutes: 1 }); 109 | } 110 | 111 | return weekdayMinutes; 112 | }; 113 | 114 | /** 115 | * Count number of night minutes between two dates 116 | */ 117 | const countNightMinutes = ({ startDate, endDate }) => { 118 | let nightMinutes = 0; 119 | let actualDate = startDate; 120 | 121 | while (isBefore(actualDate, endDate)) { 122 | if (!isSunday(actualDate)) { 123 | const hourOfDay = actualDate.getHours(); 124 | 125 | // In UTC time, 4h (UTC) is 5h in Paris (UTC+1) 126 | // In UTC time, 22h (UTC) is 23h in Paris (UTC+1) 127 | if (hourOfDay >= 22 || hourOfDay < 4) { 128 | nightMinutes++; 129 | } 130 | } 131 | 132 | actualDate = add(actualDate, { minutes: 1 }); 133 | } 134 | 135 | return nightMinutes; 136 | }; 137 | 138 | /** 139 | * Count number of sunday (dimanche) minutes between two dates 140 | */ 141 | const countSundayHolidayMinutes = ({ startDate, endDate }) => { 142 | let sundayHolidayMinutes = 0; 143 | let actualDate = startDate; 144 | 145 | while (isBefore(actualDate, endDate)) { 146 | if (isSunday(actualDate)) { 147 | sundayHolidayMinutes++; 148 | } 149 | 150 | actualDate = add(actualDate, { minutes: 1 }); 151 | } 152 | 153 | return sundayHolidayMinutes; 154 | }; 155 | 156 | export const dateUtils = { 157 | add, 158 | sub, 159 | format, 160 | isBefore, 161 | isAfter, 162 | isSameHour, 163 | isSameMinute, 164 | roundToNearestMinutes, 165 | parse, 166 | parseISO, 167 | isValid, 168 | startOfDay, 169 | endOfDay, 170 | intervalToDuration, 171 | differenceInMinutes, 172 | startOfMonth, 173 | endOfMonth, 174 | isSameMonth, 175 | lastDayOfMonth, 176 | 177 | // custom functions 178 | minutesToHoursHr, 179 | eachTimeOfInterval, 180 | countWeekdayMinutes, 181 | countNightMinutes, 182 | countSundayHolidayMinutes, 183 | }; 184 | -------------------------------------------------------------------------------- /backend/src/common/utils/ffmpeg.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | 3 | export class FfmpegService { 4 | /** 5 | * Process the video encoding with ffmpeg to optimize the video file for all web browsers. 6 | * 7 | * Safari compatibility requires: -vf "format=yuv420p" 8 | */ 9 | processVideoEncoding = async ({ inputFilePath, outputFilePath }) => { 10 | return new Promise((resolve, reject) => { 11 | const ffmpegProcess = spawn( 12 | `ffmpeg -i "${inputFilePath}" -c:v libx264 -preset fast -crf 26 -vf "format=yuv420p" -c:a aac -b:a 128k -movflags +faststart -f mp4 "${outputFilePath}"`, 13 | { shell: true } 14 | ); 15 | 16 | let stdout = ""; 17 | let stderr = ""; 18 | 19 | ffmpegProcess.stdout.on("data", (data) => { 20 | stdout += data.toString(); 21 | }); 22 | 23 | ffmpegProcess.stderr.on("data", (data) => { 24 | stderr += data.toString(); 25 | }); 26 | 27 | ffmpegProcess.on("close", (code) => { 28 | if (code === 0) { 29 | resolve({ stdout, stderr }); 30 | } else { 31 | reject( 32 | new Error( 33 | `FFmpeg process exited with code ${code}\nStderr: ${stderr}` 34 | ) 35 | ); 36 | } 37 | }); 38 | 39 | ffmpegProcess.on("error", (err) => { 40 | reject(err); 41 | }); 42 | }); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/common/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | format, 3 | createLogger, 4 | transports, 5 | Logger as WinstonLogger, 6 | } from "winston"; 7 | import { getRootDir } from "./app-dir"; 8 | import "winston-daily-rotate-file"; 9 | 10 | export class Logger { 11 | private logger: WinstonLogger; 12 | 13 | constructor(name: string) { 14 | const rootDir = getRootDir(); 15 | 16 | // Custom format to make the error logs more human readable (inspired by Laravel) 17 | const humanReadableFormat = format.printf( 18 | ({ timestamp, level, message, ...meta }) => { 19 | // Format context (additional metadata) 20 | const context = 21 | Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ""; 22 | 23 | return `[${timestamp}] ${ 24 | process.env.NODE_ENV || "local" 25 | }.${level.toUpperCase()}: ${message}${context}`; 26 | } 27 | ); 28 | 29 | this.logger = createLogger({ 30 | level: "debug", 31 | 32 | // Add service name to the log 33 | defaultMeta: { service: name }, 34 | 35 | format: format.combine( 36 | format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 37 | format.errors({ stack: true }), 38 | humanReadableFormat 39 | ), 40 | 41 | transports: [ 42 | // Console transport with colors 43 | new transports.Console({ 44 | format: format.combine(format.colorize(), format.simple()), 45 | }), 46 | 47 | // Debug logs file 48 | new transports.DailyRotateFile({ 49 | filename: `${rootDir}/logs/debug-%DATE%.log`, 50 | datePattern: "YYYY-MM-DD", 51 | maxFiles: "10", 52 | level: "debug", 53 | }), 54 | 55 | // Error logs file 56 | new transports.DailyRotateFile({ 57 | filename: `${rootDir}/logs/error-%DATE%.log`, 58 | datePattern: "YYYY-MM-DD", 59 | maxFiles: "10", 60 | level: "error", 61 | }), 62 | ], 63 | }); 64 | } 65 | 66 | error(message: string, ...meta: unknown[]) { 67 | this.logger.error(message, ...meta); 68 | } 69 | 70 | warn(message: string, ...meta: unknown[]) { 71 | this.logger.warn(message, ...meta); 72 | } 73 | 74 | info(message: string, ...meta: unknown[]) { 75 | this.logger.info(message, ...meta); 76 | } 77 | 78 | debug(message: string, ...meta: unknown[]) { 79 | this.logger.debug(message, ...meta); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /backend/src/common/utils/page-query.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get pagination values from request query. 3 | * @example ?page=1&first=10 4 | * @param req 5 | * @returns 6 | */ 7 | const getPagination = ({ 8 | query, 9 | }: { 10 | query: { page?: string | number; first?: string | number }; 11 | }) => { 12 | const page = query.page; 13 | const first = query.first; 14 | 15 | const validPage = !page || Number(page) < 1 ? 1 : Number(page); 16 | const validFirst = !first || Number(first) > 100 ? 50 : Number(first); 17 | 18 | const take = Number(validFirst); 19 | const skip = (Number(validPage) - 1) * take; 20 | 21 | return { 22 | take, 23 | skip, 24 | }; 25 | }; 26 | 27 | /** 28 | * Get sorting values from request query. 29 | * @example ?sort=example_col_name:asc 30 | * @param req 31 | * @returns 32 | */ 33 | const getSorting = ({ query }: { query: { sort?: string } }) => { 34 | const sort = query.sort; 35 | if (!sort) return undefined; 36 | 37 | const [column, direction] = sort.toString().split(":"); 38 | 39 | let validDirection = direction.toLowerCase() as "asc" | "desc"; 40 | if (validDirection !== "asc" && validDirection !== "desc") { 41 | validDirection = "desc"; 42 | } 43 | 44 | return { 45 | column, 46 | direction: validDirection, 47 | }; 48 | }; 49 | 50 | /** 51 | * Get transformed data with pagination values. 52 | * @param data - Data array 53 | * @param totalItems - Total number of items 54 | * @param first - Number of items per page 55 | * @param page - Current page number 56 | * @returns Transformed data with pagination info 57 | */ 58 | const getTransformed = ({ 59 | data, 60 | itemsCount, 61 | query, 62 | }: { 63 | data: any; 64 | itemsCount: number; 65 | query: { page?: string | number; first?: string | number }; 66 | }) => { 67 | const page = query.page; 68 | const first = query.first; 69 | const currentPage = page ? Number(page) : 1; 70 | const itemsPerPage = first ? Number(first) : 50; 71 | const pagesCount = Math.ceil(itemsCount / itemsPerPage); 72 | 73 | return { 74 | nodes: data, 75 | pagination: { 76 | currentPage, 77 | itemsPerPage, 78 | pagesCount, 79 | itemsCount, 80 | }, 81 | }; 82 | }; 83 | 84 | const getIncludes = ({ query }: { query: { include?: string[] | string } }) => { 85 | const includes = new Set(); 86 | 87 | if (Array.isArray(query.include)) { 88 | query.include.forEach((include) => includes.add(include)); 89 | } else if (typeof query.include === "string") { 90 | includes.add(query.include); 91 | } 92 | 93 | return includes; 94 | }; 95 | 96 | /** 97 | * Get both pagination and sorting parameters from request query. 98 | * @param query - Request query object containing optional pagination and sorting parameters 99 | * @returns Object with pagination and sorting properties 100 | */ 101 | const getQueryParams = ({ 102 | query, 103 | }: { 104 | query: { 105 | page?: string | number; 106 | first?: string | number; 107 | sort?: string; 108 | include?: string[] | string; 109 | }; 110 | }) => { 111 | return { 112 | pagination: getPagination({ query }), 113 | sorting: getSorting({ query }), 114 | includes: getIncludes({ query }), 115 | }; 116 | }; 117 | 118 | /** 119 | * Page query helper used for pagination, sorting and transforming data. 120 | */ 121 | export const pageQuery = { 122 | getQueryParams, 123 | getTransformed, 124 | }; 125 | -------------------------------------------------------------------------------- /backend/src/common/utils/phone-utils.ts: -------------------------------------------------------------------------------- 1 | import parsePhoneNumberFromString, { CountryCode } from "libphonenumber-js"; 2 | import { HttpException } from "../exceptions/http-exception"; 3 | 4 | const parse = (params: { phoneNumber: string; countryCode?: CountryCode }) => { 5 | const { phoneNumber, countryCode = "FR" } = params; 6 | 7 | const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); 8 | 9 | if (!parsed || !parsed.isValid()) { 10 | throw new HttpException({ 11 | status: 400, 12 | message: "Invalid phone number.", 13 | }); 14 | } 15 | 16 | return parsed; 17 | }; 18 | 19 | const isValid = (params: { 20 | phoneNumber: string; 21 | countryCode?: CountryCode; 22 | }) => { 23 | const { phoneNumber, countryCode = "FR" } = params; 24 | 25 | const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); 26 | 27 | if (!parsed || !parsed.isValid()) { 28 | return false; 29 | } 30 | 31 | return true; 32 | }; 33 | 34 | export const phoneUtils = { 35 | parse, 36 | isValid, 37 | }; 38 | -------------------------------------------------------------------------------- /backend/src/common/utils/swagger.ts: -------------------------------------------------------------------------------- 1 | import swaggerUi from "swagger-ui-express"; 2 | import swaggerJSDoc from "swagger-jsdoc"; 3 | import express from "express"; 4 | 5 | export const initializeSwagger = async ({ 6 | app, 7 | }: { 8 | app: express.Application; 9 | }) => { 10 | let swaggerSpec: any = null; 11 | 12 | // Serve Swagger JSON 13 | app.get("/api/docs/json", (req: express.Request, res: express.Response) => { 14 | if (swaggerSpec) { 15 | res.setHeader("Content-Type", "application/json"); 16 | res.send(swaggerSpec); 17 | } else { 18 | res.setHeader("Content-Type", "application/json"); 19 | res.send({ message: "Swagger documentation is being generated..." }); 20 | } 21 | }); 22 | 23 | // Serve Swagger UI 24 | app.use("/api/docs", swaggerUi.serve, (req, res, next) => { 25 | if (swaggerSpec) { 26 | swaggerUi.setup(swaggerSpec, { 27 | explorer: true, 28 | swaggerOptions: { 29 | filter: true, 30 | showRequestDuration: true, 31 | persistAuthorization: true, 32 | tagsSorter: "alpha", 33 | operationsSorter: "alpha", 34 | }, 35 | })(req, res, next); 36 | } else { 37 | res.send("Swagger documentation is being generated..."); 38 | } 39 | }); 40 | 41 | // Asynchronous Swagger generation without blocking 42 | Promise.resolve().then(async () => { 43 | swaggerSpec = await swaggerJSDoc({ 44 | definition: { 45 | openapi: "3.1.0", 46 | info: { 47 | title: "API Documentation", 48 | description: "Welcome to the API documentation.", 49 | version: "1.0.0", 50 | }, 51 | // security: [ 52 | // { 53 | // bearerAuth: [], // apply to all routes 54 | // }, 55 | // ], 56 | components: { 57 | securitySchemes: { 58 | bearerAuth: { 59 | type: "http", 60 | scheme: "bearer", 61 | name: "access-token", 62 | }, 63 | }, 64 | }, 65 | servers: [ 66 | { 67 | url: "http://localhost", 68 | description: "Local development server", 69 | }, 70 | { 71 | url: "https://dispomenage.fr", 72 | description: "Production server", 73 | }, 74 | ], 75 | }, 76 | apis: [ 77 | "./src/modules/**/*.routes.ts", 78 | "./src/modules/**/*.controller.ts", 79 | ], 80 | }); 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /backend/src/common/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { AnyZodObject, ZodEffects, ZodError } from "zod"; 2 | import { Request, Response, NextFunction } from "express"; 3 | import { ValidationException } from "@/common/exceptions/validation-exception"; 4 | import { z } from "zod"; 5 | 6 | /** 7 | * Validates request query, params and body parameters 8 | * @param validator Zod validator that will be used to validate the request 9 | */ 10 | export const validateRequest = 11 | (zodSchema: AnyZodObject | ZodEffects) => 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | // We validate the request (body, query, params, file, files..) 15 | const validatedData = await zodSchema.parseAsync({ 16 | body: req.body, 17 | query: req.query, 18 | params: req.params, 19 | file: req.file, 20 | files: req.files, 21 | }); 22 | 23 | // Replace the request body with the validated data to remove any extra fields 24 | req.body = validatedData.body; 25 | 26 | // Validation was successful, continue 27 | return next(); 28 | } catch (error) { 29 | if (error instanceof ZodError) { 30 | return next( 31 | new ValidationException({ 32 | message: error.errors[0].message, 33 | violations: error.errors.map((e) => ({ 34 | code: e.code, 35 | message: e.message, 36 | path: e.path.join("."), 37 | })), 38 | cause: error, 39 | }) 40 | ); 41 | } 42 | 43 | return next(error); 44 | } 45 | }; 46 | 47 | /** 48 | * Validates data without middleware functionality 49 | * @param zodSchema Zod validator that will be used to validate the data 50 | * @param data Object containing body, query, params, file, files to validate 51 | * @returns The validated data with proper typing from the schema 52 | */ 53 | export const validateData = async < 54 | T extends AnyZodObject | ZodEffects, 55 | >( 56 | zodSchema: T, 57 | data: { 58 | body?: any; 59 | query?: any; 60 | params?: any; 61 | file?: any; 62 | files?: any; 63 | } 64 | ): Promise> => { 65 | try { 66 | // Validate the provided data 67 | const validatedData = (await zodSchema.parseAsync(data)) as z.infer; 68 | return validatedData; 69 | } catch (error) { 70 | if (error instanceof ZodError) { 71 | throw new ValidationException({ 72 | message: error.errors[0].message, 73 | violations: error.errors.map((e) => ({ 74 | code: e.code, 75 | message: e.message, 76 | path: e.path.join("."), 77 | })), 78 | cause: error, 79 | }); 80 | } 81 | throw error; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /backend/src/common/utils/wolfios.ts: -------------------------------------------------------------------------------- 1 | type JsonValue = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | undefined 7 | | { [key: string]: JsonValue } 8 | | JsonValue[]; 9 | 10 | type WolfiosProps = Parameters[1] & { 11 | json?: JsonValue; 12 | params?: Record; 13 | cookies?: Record; 14 | timeout?: number; 15 | }; 16 | 17 | /** 18 | * Custom error class for wolfios that mimics axios error behavior 19 | */ 20 | class WolfiosError extends Error { 21 | response: Response; 22 | status: number; 23 | config: WolfiosProps; 24 | data?: unknown; // Will hold parsed response data if available 25 | 26 | constructor(message: string, response: Response, config: WolfiosProps) { 27 | super(message); 28 | this.name = "WolfiosError"; 29 | this.response = response; 30 | this.status = response.status; 31 | this.config = config; 32 | } 33 | } 34 | 35 | /** 36 | * wolfios is a wrapper around fetch that handles authentication, caching and more. 37 | * 38 | * @param url The url to request 39 | * @param config The config for the request 40 | * @param config.next The config for NextJS caching. When this is set, the request will be cached. 41 | * Also, the return response will be a JSON object, you DON'T need to chain .then((res) => res.data). 42 | */ 43 | const wolfios = async (endpoint: string, config?: WolfiosProps) => { 44 | // If we don't have a config, create an empty one 45 | if (!config) { 46 | config = {}; 47 | } 48 | 49 | // if we have a `data` param, handle it based on content type 50 | if (config?.json) { 51 | const contentType = config.headers?.["Content-Type"] || "application/json"; 52 | 53 | if (contentType === "application/x-www-form-urlencoded") { 54 | // Convert Record to Record for URLSearchParams 55 | const formData: Record = {}; 56 | Object.entries(config.json).forEach(([key, value]) => { 57 | formData[key] = String(value); 58 | }); 59 | config.body = new URLSearchParams(formData).toString(); 60 | } else { 61 | config.body = JSON.stringify(config.json); 62 | } 63 | 64 | config.headers = { 65 | "Content-Type": contentType, 66 | ...config.headers, 67 | }; 68 | } 69 | 70 | // If we have params, add them to the endpoint 71 | if (config?.params) { 72 | const searchParams = new URLSearchParams(); 73 | 74 | Object.entries(config.params).map(([key, value]) => { 75 | if (Array.isArray(value)) { 76 | value.map((v) => { 77 | if (v) { 78 | searchParams.append(key, v); 79 | } 80 | }); 81 | } else if (value) { 82 | searchParams.append(key, value.toString()); 83 | } 84 | }); 85 | 86 | endpoint = `${endpoint}?${searchParams.toString()}`; 87 | } 88 | 89 | // If we have cookies, add them to the headers 90 | if (config?.cookies) { 91 | const cookieString = Object.entries(config.cookies) 92 | .map(([key, value]) => `${key}=${value}`) 93 | .join("; "); 94 | 95 | config.headers = { 96 | ...config.headers, 97 | Cookie: cookieString, 98 | }; 99 | } 100 | 101 | // If we have a timeout, add it to the request 102 | const controller = new AbortController(); 103 | const timeout = setTimeout( 104 | () => controller.abort(), 105 | config?.timeout ?? 30000 106 | ); 107 | config.signal = controller.signal; 108 | 109 | // Make the request 110 | const response = await fetch(endpoint, config); 111 | 112 | // Clear the timeout 113 | clearTimeout(timeout); 114 | 115 | // Check if the response is successful (status code 200-299) 116 | if (!response.ok) { 117 | // Create a custom error 118 | const customError = new WolfiosError( 119 | `Request failed with status code ${response.status}`, 120 | response, 121 | config 122 | ); 123 | 124 | // Try to parse response data if possible 125 | try { 126 | const contentType = response.headers.get("content-type"); 127 | if (contentType && contentType.includes("application/json")) { 128 | customError.data = await response.clone().json(); 129 | } else { 130 | customError.data = await response.clone().text(); 131 | } 132 | } catch { 133 | // Ignore parsing errors 134 | } 135 | 136 | throw customError; 137 | } 138 | 139 | return response; 140 | }; 141 | 142 | export { wolfios, WolfiosError }; 143 | -------------------------------------------------------------------------------- /backend/src/common/utils/zod.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const initializeZod = () => { 4 | z.setErrorMap(zodFrenchErrorMap); 5 | }; 6 | 7 | const zodFrenchErrorMap = (issue, ctx) => { 8 | switch (issue.code) { 9 | case z.ZodIssueCode.invalid_type: 10 | if (issue.expected === "string") { 11 | return { message: "Ce champ doit être du texte" }; 12 | } 13 | if (issue.expected === "number") { 14 | return { message: "Ce champ doit être un nombre" }; 15 | } 16 | if (issue.expected === "integer") { 17 | return { message: "Ce champ doit être un nombre entier" }; 18 | } 19 | if (issue.expected === "float") { 20 | return { message: "Ce champ doit être un nombre décimal" }; 21 | } 22 | if (issue.expected === "boolean") { 23 | return { message: "Ce champ doit être vrai ou faux" }; 24 | } 25 | if (issue.expected === "date") { 26 | return { message: "Ce champ doit être une date valide" }; 27 | } 28 | if (issue.expected === "bigint") { 29 | return { message: "Ce champ doit être un très grand nombre" }; 30 | } 31 | if (issue.expected === "undefined") { 32 | return { message: "Ce champ ne doit pas être défini" }; 33 | } 34 | if (issue.expected === "null") { 35 | return { message: "Ce champ doit être vide" }; 36 | } 37 | if (issue.expected === "array") { 38 | return { message: "Ce champ doit être une liste" }; 39 | } 40 | if (issue.expected === "object") { 41 | return { message: "Ce champ doit être un objet" }; 42 | } 43 | if (issue.expected === "function") { 44 | return { message: "Ce champ doit être une fonction" }; 45 | } 46 | return { 47 | message: `Type attendu : ${issue.expected}, type reçu : ${issue.received}`, 48 | }; 49 | 50 | case z.ZodIssueCode.invalid_literal: 51 | return { 52 | message: `La valeur doit être exactement : ${JSON.stringify( 53 | issue.expected 54 | )}`, 55 | }; 56 | 57 | case z.ZodIssueCode.unrecognized_keys: { 58 | const keys = issue.keys.map((k) => `"${k}"`).join(", "); 59 | return { 60 | message: `Propriété${issue.keys.length > 1 ? "s" : ""} non autorisée${ 61 | issue.keys.length > 1 ? "s" : "" 62 | } : ${keys}`, 63 | }; 64 | } 65 | 66 | case z.ZodIssueCode.invalid_union: 67 | return { 68 | message: "Les données fournies ne correspondent à aucun format attendu", 69 | }; 70 | 71 | case z.ZodIssueCode.invalid_union_discriminator: { 72 | const options = issue.options.map((opt) => `"${opt}"`).join(" ou "); 73 | return { 74 | message: `La valeur du discriminateur doit être : ${options}`, 75 | }; 76 | } 77 | 78 | case z.ZodIssueCode.invalid_enum_value: { 79 | const enumOptions = issue.options.map((opt) => `"${opt}"`).join(", "); 80 | return { 81 | message: `Valeur non autorisée. Valeurs possibles : ${enumOptions}. Valeur reçue : "${issue.received}"`, 82 | }; 83 | } 84 | 85 | case z.ZodIssueCode.invalid_arguments: 86 | return { message: "Les arguments de la fonction ne sont pas valides" }; 87 | 88 | case z.ZodIssueCode.invalid_return_type: 89 | return { message: "Le type de retour de la fonction n'est pas valide" }; 90 | 91 | case z.ZodIssueCode.invalid_date: 92 | return { message: "La date fournie n'est pas valide" }; 93 | 94 | case z.ZodIssueCode.invalid_string: 95 | if (issue.validation === "email") { 96 | return { message: "Ce champ doit être une adresse e-mail valide" }; 97 | } 98 | if (issue.validation === "url") { 99 | return { message: "Ce champ doit être une adresse web (URL) valide" }; 100 | } 101 | if (issue.validation === "emoji") { 102 | return { message: "Ce champ doit contenir un emoji valide" }; 103 | } 104 | if (issue.validation === "uuid") { 105 | return { 106 | message: "Ce champ doit être un identifiant unique (UUID) valide", 107 | }; 108 | } 109 | if (issue.validation === "nanoid") { 110 | return { message: "Ce champ doit être un identifiant NanoID valide" }; 111 | } 112 | if (issue.validation === "cuid") { 113 | return { message: "Ce champ doit être un identifiant CUID valide" }; 114 | } 115 | if (issue.validation === "cuid2") { 116 | return { message: "Ce champ doit être un identifiant CUID2 valide" }; 117 | } 118 | if (issue.validation === "ulid") { 119 | return { message: "Ce champ doit être un identifiant ULID valide" }; 120 | } 121 | if (issue.validation === "datetime") { 122 | return { 123 | message: "Ce champ doit respecter le format de date et heure", 124 | }; 125 | } 126 | if (issue.validation === "date") { 127 | return { message: "Ce champ doit respecter le format de date" }; 128 | } 129 | if (issue.validation === "time") { 130 | return { message: "Ce champ doit respecter le format d'heure" }; 131 | } 132 | if (issue.validation === "duration") { 133 | return { message: "Ce champ doit respecter le format de durée" }; 134 | } 135 | if (issue.validation === "ip") { 136 | return { message: "Ce champ doit être une adresse IP valide" }; 137 | } 138 | if (issue.validation === "base64") { 139 | return { message: "Ce champ doit être au format Base64 valide" }; 140 | } 141 | if (issue.validation === "startsWith") { 142 | return { message: `Ce champ doit commencer par "${issue.startsWith}"` }; 143 | } 144 | if (issue.validation === "endsWith") { 145 | return { message: `Ce champ doit se terminer par "${issue.endsWith}"` }; 146 | } 147 | if (issue.validation === "regex") { 148 | return { message: "Ce champ ne respecte pas le format attendu" }; 149 | } 150 | if (issue.validation === "includes") { 151 | return { message: `Ce champ doit contenir "${issue.includes}"` }; 152 | } 153 | return { message: "Ce champ n'a pas un format valide" }; 154 | 155 | case z.ZodIssueCode.too_small: 156 | if (issue.type === "array") { 157 | if (issue.exact) { 158 | return { 159 | message: `Ce champ doit contenir exactement ${ 160 | issue.minimum 161 | } élément${issue.minimum > 1 ? "s" : ""}`, 162 | }; 163 | } 164 | return { 165 | message: `Ce champ doit contenir au moins ${issue.minimum} élément${ 166 | issue.minimum > 1 ? "s" : "" 167 | }`, 168 | }; 169 | } 170 | if (issue.type === "string") { 171 | if (issue.exact) { 172 | return { 173 | message: `Ce champ doit contenir exactement ${ 174 | issue.minimum 175 | } caractère${issue.minimum > 1 ? "s" : ""}`, 176 | }; 177 | } 178 | return { 179 | message: `Ce champ doit contenir au moins ${issue.minimum} caractère${ 180 | issue.minimum > 1 ? "s" : "" 181 | }`, 182 | }; 183 | } 184 | if (issue.type === "number") { 185 | if (issue.exact) { 186 | return { 187 | message: `Ce champ doit être exactement ${issue.minimum}`, 188 | }; 189 | } 190 | return { 191 | message: `Ce champ doit être supérieur ou égal à ${issue.minimum}`, 192 | }; 193 | } 194 | if (issue.type === "date") { 195 | return { 196 | message: `Ce champ doit être postérieur ou égal au ${new Date( 197 | issue.minimum 198 | ).toLocaleDateString("fr-FR")}`, 199 | }; 200 | } 201 | if (issue.type === "bigint") { 202 | if (issue.exact) { 203 | return { 204 | message: `Ce champ doit être exactement ${issue.minimum}`, 205 | }; 206 | } 207 | return { 208 | message: `Ce champ doit être supérieur ou égal à ${issue.minimum}`, 209 | }; 210 | } 211 | return { message: "Ce champ a une valeur trop petite" }; 212 | 213 | case z.ZodIssueCode.too_big: 214 | if (issue.type === "array") { 215 | if (issue.exact) { 216 | return { 217 | message: `Ce champ doit contenir exactement ${ 218 | issue.maximum 219 | } élément${issue.maximum > 1 ? "s" : ""}`, 220 | }; 221 | } 222 | return { 223 | message: `Ce champ doit contenir au maximum ${issue.maximum} élément${ 224 | issue.maximum > 1 ? "s" : "" 225 | }`, 226 | }; 227 | } 228 | if (issue.type === "string") { 229 | if (issue.exact) { 230 | return { 231 | message: `Ce champ doit contenir exactement ${ 232 | issue.maximum 233 | } caractère${issue.maximum > 1 ? "s" : ""}`, 234 | }; 235 | } 236 | return { 237 | message: `Ce champ doit contenir au maximum ${ 238 | issue.maximum 239 | } caractère${issue.maximum > 1 ? "s" : ""}`, 240 | }; 241 | } 242 | if (issue.type === "number") { 243 | if (issue.exact) { 244 | return { 245 | message: `Ce champ doit être exactement ${issue.maximum}`, 246 | }; 247 | } 248 | return { 249 | message: `Ce champ doit être inférieur ou égal à ${issue.maximum}`, 250 | }; 251 | } 252 | if (issue.type === "date") { 253 | return { 254 | message: `Ce champ doit être antérieur ou égal au ${new Date( 255 | issue.maximum 256 | ).toLocaleDateString("fr-FR")}`, 257 | }; 258 | } 259 | if (issue.type === "bigint") { 260 | if (issue.exact) { 261 | return { 262 | message: `Ce champ doit être exactement ${issue.maximum}`, 263 | }; 264 | } 265 | return { 266 | message: `Ce champ doit être inférieur ou égal à ${issue.maximum}`, 267 | }; 268 | } 269 | return { message: "Ce champ a une valeur trop grande" }; 270 | 271 | case z.ZodIssueCode.invalid_intersection_types: 272 | return { 273 | message: "Impossible de combiner les types de données requis", 274 | }; 275 | 276 | case z.ZodIssueCode.not_multiple_of: 277 | return { 278 | message: `Ce champ doit être un multiple de ${issue.multipleOf}`, 279 | }; 280 | 281 | case z.ZodIssueCode.not_finite: 282 | return { message: "Ce champ doit être un nombre fini (pas infini)" }; 283 | 284 | case z.ZodIssueCode.custom: 285 | if (issue.message) { 286 | return { message: issue.message }; 287 | } 288 | return { message: "Les données fournies ne sont pas valides" }; 289 | 290 | default: 291 | return { message: ctx.defaultError }; 292 | } 293 | }; 294 | -------------------------------------------------------------------------------- /backend/src/common/validators/bic.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const bicRegex = /^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/; 4 | 5 | export const bicValidator = z 6 | .string() 7 | .transform((v) => v.replace(/[ _]/g, "")) 8 | 9 | .pipe(z.string().regex(bicRegex, { message: "This BIC number is invalid." })); 10 | -------------------------------------------------------------------------------- /backend/src/common/validators/iban.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import isIban from "validator/lib/isIBAN"; 3 | 4 | const ibanRegex = /^([A-Z]{2})(\d{2})([A-Z\d]{1,30})$/; 5 | 6 | export const ibanValidator = z 7 | .string() 8 | .transform((v) => v.replace(/[ _]/g, "")) // Remove spaces and underscores 9 | .pipe( 10 | z.string().regex(ibanRegex, { message: "This IBAN number is incorrect." }) 11 | ) 12 | .pipe( 13 | z.string().refine((v) => isIban(v), { 14 | message: 15 | "This IBAN number does not match the country format or is invalid.", 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /backend/src/common/validators/phone-number.validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { phoneUtils } from "@/common/utils/phone-utils"; 3 | 4 | export const phoneNumberValidator = z.string().refine((value) => { 5 | return phoneUtils.isValid({ phoneNumber: value }); 6 | }, "Le numéro de téléphone est invalide"); 7 | -------------------------------------------------------------------------------- /backend/src/common/views/invoice.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Invoice 6 | 10 | 11 | 12 |
13 | Hello world! 14 |
15 | You can use any tailwindcss library classes to style your invoice ! 16 | 17 | 18 | -------------------------------------------------------------------------------- /backend/src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Load environments variables from .env.${environment} possible values development || test 6 | * any environment that will be deployed will use the environment variables setup in the own environment 7 | */ 8 | const environment = process.env.NODE_ENV || "development"; 9 | 10 | dotenv.config({ path: [".env", `.env.${environment}`] }); 11 | 12 | const envSchema = z.object({ 13 | NODE_ENV: z.enum(["development", "test", "production"]), 14 | APP_BASE_URL: z.string().url(), 15 | APP_PORT: z.coerce.number().default(3000), 16 | APP_DATABASE_CONNECTION_URL: z.string().url(), 17 | APP_REDIS_HOST: z.string().min(1), 18 | APP_REDIS_PORT: z.coerce.number().default(6379), 19 | APP_JWT_SECRET_KEY: z.string().min(1), 20 | APP_JWT_PUBLIC_KEY: z.string().min(1), 21 | API_S3_ENDPOINT: z.string().url(), 22 | API_S3_ACCESS_KEY: z.string().min(1), 23 | API_S3_SECRET_KEY: z.string().min(1), 24 | API_S3_BUCKET: z.string().min(1), 25 | API_GOOGLE_CLIENT_ID: z.string().min(1), 26 | API_GOOGLE_CLIENT_SECRET: z.string().min(1), 27 | APP_PLAYWRIGHT_HEADLESS: z.coerce.boolean().default(true), 28 | }); 29 | 30 | export const env = envSchema.parse(process.env); 31 | -------------------------------------------------------------------------------- /backend/src/core/use-cases/test-write-text.usecase.ts: -------------------------------------------------------------------------------- 1 | class TestWriteTextUseCase { 2 | execute = () => { 3 | // Add very complex business logic here. 4 | // You can call services, repositories, etc. from multiple modules. 5 | return "Hello World"; 6 | }; 7 | } 8 | 9 | export const testWriteTextUseCase = new TestWriteTextUseCase(); 10 | -------------------------------------------------------------------------------- /backend/src/instrument.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | 3 | Sentry.init({ 4 | dsn: "SENTRY_DSN", 5 | }); 6 | -------------------------------------------------------------------------------- /backend/src/modules/auth/auth.config.ts: -------------------------------------------------------------------------------- 1 | export const authConfig = { 2 | accessTokenExpirationMinutes: 15, // 15 minutes 3 | refreshTokenExpirationMinutes: 60 * 24 * 60, // 60 days 4 | passwordResetTokenExpirationHours: 1, 5 | }; 6 | -------------------------------------------------------------------------------- /backend/src/modules/auth/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { prisma } from "@/common/database/prisma"; 3 | import { HttpException } from "@/common/exceptions/http-exception"; 4 | import { Customer } from "@/generated/prisma/client"; 5 | import { Admin } from "@/generated/prisma/client"; 6 | import { authService } from "@/common/services/auth.service"; 7 | import { validateData } from "@/common/utils/validation"; 8 | import { 9 | authRefreshTokenValidator, 10 | authSigninValidator, 11 | } from "../validators/auth.validator"; 12 | import { authConfig } from "../auth.config"; 13 | 14 | export class AuthController { 15 | signIn = async (req: Request, res: Response, next: NextFunction) => { 16 | try { 17 | const { body } = await validateData(authSigninValidator, { 18 | body: req.body, 19 | }); 20 | 21 | // Verify if user exists 22 | let user: Admin | Customer | null = null; 23 | if (body.role === "ADMIN") { 24 | user = await prisma.admin.findFirst({ 25 | where: { 26 | email: { 27 | contains: body.email, 28 | mode: "insensitive", 29 | }, 30 | }, 31 | }); 32 | } else if (body.role === "CUSTOMER") { 33 | user = await prisma.customer.findFirst({ 34 | where: { 35 | email: { 36 | contains: body.email, 37 | mode: "insensitive", 38 | }, 39 | }, 40 | }); 41 | } 42 | 43 | if (!user) { 44 | throw HttpException.badRequest({ 45 | message: "Mot de passe ou e-mail incorrect.", 46 | }); 47 | } 48 | 49 | if (!user.password) { 50 | throw HttpException.badRequest({ 51 | message: 52 | "You have previously signed up with another service like Google, please use the appropriate login method for this account.", 53 | }); 54 | } 55 | 56 | // Hash given password and compare it to the stored hash 57 | const validPassword = await authService.comparePassword({ 58 | password: body.password, 59 | hashedPassword: user.password, 60 | }); 61 | 62 | if (!validPassword) { 63 | throw HttpException.badRequest({ 64 | message: "Mot de passe ou e-mail incorrect.", 65 | }); 66 | } 67 | 68 | // Generate an access token 69 | const { accessToken, refreshToken } = 70 | await authService.generateAuthenticationTokens({ 71 | accountId: user.accountId, 72 | }); 73 | 74 | res.cookie("lunisoft_access_token", accessToken, { 75 | secure: process.env.NODE_ENV === "production", 76 | maxAge: authConfig.accessTokenExpirationMinutes * 60 * 1000, // Convert minutes to milliseconds 77 | }); 78 | 79 | res.cookie("lunisoft_refresh_token", refreshToken, { 80 | httpOnly: true, 81 | secure: process.env.NODE_ENV === "production", 82 | maxAge: authConfig.refreshTokenExpirationMinutes * 60 * 1000, // Convert minutes to milliseconds 83 | }); 84 | 85 | return res.json({ 86 | accessToken, 87 | refreshToken, 88 | }); 89 | } catch (error) { 90 | next(error); 91 | } 92 | }; 93 | 94 | refreshToken = async (req: Request, res: Response, next: NextFunction) => { 95 | try { 96 | const { body } = await validateData(authRefreshTokenValidator, { 97 | body: req.body, 98 | }); 99 | 100 | let previousRefreshToken = 101 | body.refreshToken ?? req.cookies?.lunisoft_refresh_token; 102 | 103 | if (!previousRefreshToken) { 104 | throw HttpException.badRequest({ 105 | message: 106 | "Refresh token not found. Please set it in the body parameter or in your cookies.", 107 | }); 108 | } 109 | const { accessToken, refreshToken } = await authService 110 | .refreshAuthenticationTokens({ 111 | refreshToken: previousRefreshToken, 112 | }) 113 | .catch((error) => { 114 | throw HttpException.badRequest({ 115 | message: error.message, 116 | }); 117 | }); 118 | 119 | res.cookie("lunisoft_access_token", accessToken, { 120 | secure: process.env.NODE_ENV === "production", 121 | maxAge: authConfig.accessTokenExpirationMinutes * 60 * 1000, // Convert minutes to milliseconds 122 | }); 123 | 124 | res.cookie("lunisoft_refresh_token", refreshToken, { 125 | httpOnly: true, 126 | secure: process.env.NODE_ENV === "production", 127 | maxAge: authConfig.refreshTokenExpirationMinutes * 60 * 1000, // Convert minutes to milliseconds 128 | }); 129 | 130 | return res.json({ 131 | accessToken, 132 | refreshToken, 133 | }); 134 | } catch (error) { 135 | next(error); 136 | } 137 | }; 138 | } 139 | 140 | export const authController = new AuthController(); 141 | -------------------------------------------------------------------------------- /backend/src/modules/auth/routes/auth.routes.ts: -------------------------------------------------------------------------------- 1 | import { strictThrottler } from "@/common/throttlers/strict.throttler"; 2 | import { Router } from "express"; 3 | import { authController } from "../controllers/auth.controller"; 4 | 5 | export const authRoutes = Router(); 6 | 7 | /** 8 | * @summary: Sign in 9 | * @description: Sign in as admin or customer 10 | */ 11 | authRoutes.post("/auth/signin", strictThrottler, authController.signIn); 12 | 13 | /** 14 | * @summary: Refresh token 15 | * @description: Generate a new access token from a refresh token 16 | */ 17 | authRoutes.post("/auth/refresh", authController.refreshToken); 18 | -------------------------------------------------------------------------------- /backend/src/modules/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; 2 | import passport from "passport"; 3 | import { Account } from "@/generated/prisma/client"; 4 | import { prisma } from "@/common/database/prisma"; 5 | import { env } from "@/config"; 6 | 7 | export const initializeJwtStrategy = async () => { 8 | passport.use( 9 | new JwtStrategy( 10 | { 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | algorithms: ["RS256"], // Recommended algorithm for JWT (Asymmetric, uses a private key to sign and a public key to verify.). The default one is HS256 (Symmetric, uses a single secret key for both signing and verifying). 13 | secretOrKey: env.APP_JWT_PUBLIC_KEY, 14 | }, 15 | async ( 16 | payload: { sub: string }, 17 | done: (error: Error | null, account?: Account | false) => void 18 | ) => { 19 | try { 20 | const sessionId = payload.sub; 21 | 22 | // Get account by id 23 | const account = await prisma.account.findFirst({ 24 | include: { 25 | admin: true, 26 | customer: true, 27 | }, 28 | where: { session: { some: { id: sessionId } } }, 29 | }); 30 | 31 | if (!account) { 32 | return done(null, false); 33 | } 34 | 35 | return done(null, account); 36 | } catch { 37 | return done(null, false); 38 | } 39 | } 40 | ) 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /backend/src/modules/auth/tests/auth.controller.test.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from "@/app"; 2 | import { authService } from "@/common/services/auth.service"; 3 | import { initializeTestDb } from "@/common/test/jest-initialize-db"; 4 | import { getAdminUserAccessToken } from "@/common/test/jest-utils"; 5 | import { Express } from "express"; 6 | import request from "supertest"; 7 | 8 | let app: Express; 9 | 10 | beforeAll(async () => { 11 | app = await bootstrap(); 12 | }); 13 | 14 | beforeEach(async () => { 15 | // Reset to initial state before each test 16 | await initializeTestDb(); 17 | }); 18 | 19 | describe("AuthController", () => { 20 | describe("POST /api/auth/signin", () => { 21 | it("returns a status code 200 if the signin is successful as admin", async () => { 22 | const response = await request(app).post("/api/auth/signin").send({ 23 | email: "contact@lunisoft.fr", 24 | password: "password", 25 | role: "ADMIN", 26 | }); 27 | 28 | expect(response.status).toEqual(200); 29 | expect(response.body.accessToken).toBeDefined(); 30 | expect(response.body.refreshToken).toBeDefined(); 31 | }); 32 | 33 | it("returns a status code 400 if the role is incorrect", async () => { 34 | const response = await request(app).post("/api/auth/signin").send({ 35 | email: "contact@lunisoft.fr", 36 | password: "password", 37 | role: "INCORRECT-ROLE", 38 | }); 39 | 40 | expect(response.status).toEqual(400); 41 | expect(response.body.message).toEqual( 42 | "Invalid enum value. Expected 'ADMIN' | 'CUSTOMER', received 'INCORRECT-ROLE'" 43 | ); 44 | }); 45 | 46 | it("returns a status code 400 if the password is incorrect", async () => { 47 | const response = await request(app).post("/api/auth/signin").send({ 48 | email: "contact@lunisoft.fr", 49 | password: "password-incorrect", 50 | role: "ADMIN", 51 | }); 52 | 53 | expect(response.status).toEqual(400); 54 | expect(response.body.message).toEqual( 55 | "Mot de passe ou e-mail incorrect." 56 | ); 57 | }); 58 | }); 59 | 60 | describe("POST /api/auth/refresh", () => { 61 | it("returns a status code 200 if the refresh token has been refreshed", async () => { 62 | const refreshToken = await getAdminUserAccessToken(); 63 | 64 | const response = await request(app).post("/api/auth/refresh").send({ 65 | refreshToken, 66 | }); 67 | 68 | expect(response.status).toEqual(200); 69 | expect(response.body.accessToken).toBeDefined(); 70 | expect(response.body.refreshToken).toBeDefined(); 71 | }); 72 | 73 | it("returns a status code 400 if the refresh token is invalid", async () => { 74 | const response = await request(app).post("/api/auth/refresh").send({ 75 | refreshToken: "invalid-refresh-token", 76 | }); 77 | 78 | expect(response.status).toEqual(400); 79 | expect(response.body.message).toEqual( 80 | "Invalid or expired refresh token." 81 | ); 82 | }); 83 | 84 | it("returns a status code 400 if the refresh token is expired", async () => { 85 | // Generate the JWT refresh token 86 | const expiredRefreshToken = await authService.signJwt({ 87 | payload: { 88 | sub: "1234", 89 | }, 90 | options: { 91 | expiresIn: `-1m`, 92 | }, 93 | }); 94 | 95 | const response = await request(app).post("/api/auth/refresh").send({ 96 | refreshToken: expiredRefreshToken, 97 | }); 98 | 99 | expect(response.status).toEqual(400); 100 | expect(response.body.message).toEqual( 101 | "Invalid or expired refresh token." 102 | ); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /backend/src/modules/auth/validators/auth.validator.ts: -------------------------------------------------------------------------------- 1 | import { Role } from "@/generated/prisma/client"; 2 | import { z } from "zod"; 3 | 4 | export const authSigninValidator = z.object({ 5 | body: z.object({ 6 | email: z.string().email(), 7 | password: z.string().min(1), 8 | role: z.nativeEnum(Role), 9 | }), 10 | }); 11 | 12 | export const authRefreshTokenValidator = z.object({ 13 | body: z.object({ 14 | refreshToken: z.string().min(1).optional(), 15 | }), 16 | }); 17 | -------------------------------------------------------------------------------- /backend/src/modules/me/controllers/me.controller.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from "@/common/exceptions/http-exception"; 2 | import { NextFunction, Request, Response } from "express"; 3 | 4 | export class MeController { 5 | getMe = async (req: Request, res: Response, next: NextFunction) => { 6 | try { 7 | const account = req.context?.account; 8 | 9 | if (account?.role === "CUSTOMER") { 10 | return res.json({ 11 | id: account.customer.id, 12 | email: account.customer.email, 13 | role: account.role, 14 | }); 15 | } else if (account?.role === "ADMIN") { 16 | return res.json({ 17 | id: account.admin.id, 18 | email: account.admin.email, 19 | role: account.role, 20 | }); 21 | } else { 22 | throw new HttpException({ 23 | status: 400, 24 | message: "Invalid role.", 25 | }); 26 | } 27 | } catch (error) { 28 | next(error); 29 | } 30 | }; 31 | } 32 | 33 | export const meController = new MeController(); 34 | -------------------------------------------------------------------------------- /backend/src/modules/me/routes/me.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { sessionsGuard } from "@/common/guards/sessions.guard"; 3 | import { meController } from "../controllers/me.controller"; 4 | 5 | export const meRoutes = Router(); 6 | 7 | /** 8 | * @swagger 9 | * /api/me: 10 | * get: 11 | * summary: Get the current logged-in user's information. 12 | * description: Get the current logged-in user's information. 13 | * tags: [Me] 14 | * security: 15 | * - bearerAuth: [] 16 | * responses: 17 | * '200': 18 | * description: OK 19 | */ 20 | meRoutes.get("/me", sessionsGuard, meController.getMe); 21 | -------------------------------------------------------------------------------- /backend/src/modules/media/controllers/media.controller.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | import { HttpException } from "@/common/exceptions/http-exception"; 3 | import { mediaService } from "../media.service"; 4 | import { queueService } from "@/common/queue/queue.service"; 5 | import { s3Service } from "@/common/storage/s3"; 6 | 7 | export class MediaController { 8 | uploadMedia = async (req: Request, res: Response, next: NextFunction) => { 9 | try { 10 | const file = req.file; 11 | 12 | if (!file) { 13 | throw new HttpException({ 14 | status: 400, 15 | message: "No file uploaded.", 16 | }); 17 | } 18 | 19 | // Verify the file 20 | await mediaService.verifyMulterMaxSizeAndMimeType({ 21 | file: file, 22 | allowedMimeTypes: [ 23 | "image/jpeg", 24 | "image/jpg", 25 | "image/png", 26 | "application/pdf", 27 | ], 28 | maxFileSize: 50, 29 | }); 30 | 31 | // Upload the file to S3 32 | const media = await mediaService.uploadFileToS3({ 33 | filePath: file.path, 34 | originalFileName: file.originalname, 35 | }); 36 | 37 | // Get the presigned url for the file 38 | const presignedUrl = await s3Service.getPresignedUrl({ 39 | key: media.key, 40 | }); 41 | 42 | return res.json({ 43 | id: media.id, 44 | presignedUrl, 45 | status: "success", 46 | }); 47 | } catch (error) { 48 | next(error); 49 | } 50 | }; 51 | 52 | uploadVideo = async (req: Request, res: Response, next: NextFunction) => { 53 | try { 54 | const file = req.file; 55 | 56 | if (!file) { 57 | throw new HttpException({ 58 | status: 400, 59 | message: "No file uploaded.", 60 | }); 61 | } 62 | 63 | // Verify the file 64 | await mediaService.verifyMulterMaxSizeAndMimeType({ 65 | file: file, 66 | allowedMimeTypes: ["video/mp4", "video/quicktime"], 67 | maxFileSize: 100, 68 | }); 69 | 70 | // Upload the file to S3 71 | const media = await mediaService.uploadFileToS3({ 72 | filePath: file.path, 73 | originalFileName: file.originalname, 74 | }); 75 | 76 | // Optimize the video file with ffmpeg and reupload it to S3 77 | queueService.addOptimizeVideoJob(media.id); 78 | 79 | return res.json({ 80 | status: "success", 81 | id: media.id, 82 | }); 83 | } catch (error) { 84 | next(error); 85 | } 86 | }; 87 | } 88 | 89 | export const mediaController = new MediaController(); 90 | -------------------------------------------------------------------------------- /backend/src/modules/media/media.service.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "@/common/database/prisma"; 2 | import { s3Service } from "@/common/storage/s3"; 3 | import { HttpException } from "@/common/exceptions/http-exception"; 4 | import { filesService } from "@/common/services/files.service"; 5 | import { Logger } from "@/common/utils/logger"; 6 | import { Express } from "express"; 7 | 8 | export class MediaService { 9 | private readonly logger = new Logger("mediaService"); 10 | 11 | /** 12 | * Save a file uploaded with Multer to S3 and create a media record. 13 | * 14 | * @param params.filePath The path to the file 15 | * @param params.originalFileName The original file name 16 | * @returns The media record 17 | */ 18 | uploadFileToS3 = async ({ 19 | filePath, 20 | originalFileName, 21 | }: { 22 | filePath: string; 23 | originalFileName: string; 24 | }) => { 25 | const fileInfos = await filesService.getFileInfos(filePath); 26 | 27 | // -- Save the file to S3 28 | const key = await s3Service.upload({ 29 | filePath: filePath, 30 | fileName: originalFileName, 31 | mimeType: fileInfos.mimeType, 32 | }); 33 | 34 | const media = await prisma.media.create({ 35 | data: { 36 | key: key, 37 | fileName: originalFileName, 38 | mimeType: fileInfos.mimeType, 39 | size: fileInfos.size, 40 | }, 41 | }); 42 | 43 | return media; 44 | }; 45 | 46 | /** 47 | * Verify that the file has the correct size and type. 48 | * Throws an exception if the file does not meet the requirements. 49 | * 50 | * @param params.file The file to verify 51 | * @param params.allowedTypes The allowed MIME types 52 | * @param params.maxFileSize The maximum file size in Mo 53 | */ 54 | verifyMulterMaxSizeAndMimeType = async ({ 55 | file, 56 | allowedMimeTypes, 57 | maxFileSize, 58 | }: { 59 | file: Express.Multer.File; 60 | allowedMimeTypes: string[]; 61 | maxFileSize: number; 62 | }) => { 63 | const maxFileSizeInBytes = maxFileSize * 1024 * 1024; // Convert Mo to bytes 64 | const fileInfos = await filesService.getFileInfos(file.path); 65 | 66 | if (!allowedMimeTypes.includes(fileInfos.mimeType)) { 67 | throw new HttpException({ 68 | status: 415, 69 | message: "This file type is not supported.", 70 | }); 71 | } 72 | 73 | if (file.size > maxFileSizeInBytes) { 74 | throw new HttpException({ 75 | status: 413, 76 | message: `The file size must not exceed ${maxFileSize} Mo.`, 77 | }); 78 | } 79 | 80 | return true; 81 | }; 82 | 83 | /** 84 | * Verify that the media has the correct size and type. 85 | * Throws an exception if the media does not meet the requirements. 86 | * 87 | * @param params.mediaId The media ID 88 | * @param params.allowedMimeTypes The allowed MIME types 89 | * @param params.maxFileSize The maximum file size in Mo 90 | */ 91 | verifyMediaMaxSizeAndMimeType = async ({ 92 | mediaId, 93 | allowedMimeTypes, 94 | maxFileSize, 95 | }: { 96 | mediaId: string; 97 | allowedMimeTypes: string[]; 98 | maxFileSize: number; 99 | }) => { 100 | const maxFileSizeInBytes = maxFileSize * 1024 * 1024; // Convert Mo to bytes 101 | 102 | const media = await prisma.media.findUnique({ 103 | where: { 104 | id: mediaId, 105 | }, 106 | }); 107 | 108 | if (!media) { 109 | throw new HttpException({ 110 | status: 404, 111 | message: "Media to verify cannot be found.", 112 | }); 113 | } 114 | 115 | if (!allowedMimeTypes.includes(media.mimeType)) { 116 | throw new HttpException({ 117 | status: 415, 118 | message: "This file type is not allowed.", 119 | }); 120 | } 121 | 122 | if (media.size > maxFileSizeInBytes) { 123 | throw new HttpException({ 124 | status: 413, 125 | message: `The file size must not exceed ${maxFileSize} Mo.`, 126 | }); 127 | } 128 | 129 | return true; 130 | }; 131 | } 132 | 133 | export const mediaService = new MediaService(); 134 | -------------------------------------------------------------------------------- /backend/src/modules/media/routes/media.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import multer from "multer"; 3 | import { mediaController } from "../controllers/media.controller"; 4 | 5 | export const mediaRoutes = Router(); 6 | 7 | const fileInterceptor = multer({ 8 | storage: multer.diskStorage({}), 9 | limits: { 10 | files: 1, 11 | }, 12 | }); 13 | 14 | /** 15 | * @swagger 16 | * /api/media: 17 | * post: 18 | * summary: Upload a media 19 | * description: Upload a media to the server. 20 | * tags: [Media] 21 | * requestBody: 22 | * required: true 23 | * content: 24 | * multipart/form-data: 25 | * schema: 26 | * type: object 27 | * properties: 28 | * file: 29 | * type: string 30 | * format: binary 31 | * responses: 32 | * '200': 33 | * description: OK 34 | */ 35 | mediaRoutes.post( 36 | "/media", 37 | fileInterceptor.single("file"), 38 | mediaController.uploadMedia 39 | ); 40 | 41 | /** 42 | * @swagger 43 | * /api/media/video: 44 | * post: 45 | * summary: Upload a video media 46 | * description: Upload a video media to the server. 47 | * tags: [Media] 48 | * requestBody: 49 | * required: true 50 | * content: 51 | * multipart/form-data: 52 | * schema: 53 | * type: object 54 | * properties: 55 | * file: 56 | * type: string 57 | * format: binary 58 | * responses: 59 | * '200': 60 | * description: OK 61 | */ 62 | mediaRoutes.post( 63 | "/media/video", 64 | fileInterceptor.single("file"), 65 | mediaController.uploadVideo 66 | ); 67 | -------------------------------------------------------------------------------- /backend/src/modules/test-author/test-author.service.ts: -------------------------------------------------------------------------------- 1 | export class TestAuthorService { 2 | sayHello = () => { 3 | return "Hello World"; 4 | }; 5 | } 6 | 7 | export const testAuthorService = new TestAuthorService(); 8 | -------------------------------------------------------------------------------- /backend/src/modules/test/controllers/test.controller.ts: -------------------------------------------------------------------------------- 1 | import { eventsService } from "@/common/events/events.service"; 2 | import { HttpException } from "@/common/exceptions/http-exception"; 3 | import { queueService } from "@/common/queue/queue.service"; 4 | import { pdfService } from "@/common/services/pdf.service"; 5 | import { getAppDir } from "@/common/utils/app-dir"; 6 | import { validateData } from "@/common/utils/validation"; 7 | import { testWriteTextUseCase } from "@/core/use-cases/test-write-text.usecase"; 8 | import ejs from "ejs"; 9 | import { NextFunction, Request, Response } from "express"; 10 | import fs from "fs/promises"; 11 | import path from "path"; 12 | import { toAccountDto } from "../dtos/account.dto"; 13 | import { testConfig } from "../test.config"; 14 | import { testService } from "../test.service"; 15 | import { updateAccountValidator } from "../validators/test.validators"; 16 | 17 | export class TestController { 18 | testBadRequest = async (req: Request, res: Response, next: NextFunction) => { 19 | try { 20 | throw HttpException.badRequest({ 21 | message: "An error occurred.", 22 | code: "TEST_BAD_REQUEST", 23 | }); 24 | 25 | // throw new HttpException({ 26 | // status: 400, 27 | // body: "An error occurred.", 28 | // code: "TEST_BAD_REQUEST", 29 | // }); 30 | } catch (error) { 31 | next(error); 32 | } 33 | }; 34 | 35 | testQueueLaunch = async (req: Request, res: Response, next: NextFunction) => { 36 | try { 37 | queueService.addTestingJob("Hello World"); 38 | 39 | return res.json({ 40 | message: "Job added to queue.", 41 | }); 42 | } catch (error) { 43 | next(error); 44 | } 45 | }; 46 | 47 | testZod = async (req: Request, res: Response, next: NextFunction) => { 48 | try { 49 | const { body } = await validateData(updateAccountValidator, { 50 | body: req.body, 51 | }); 52 | 53 | return res.json(body); 54 | } catch (error) { 55 | next(error); 56 | } 57 | }; 58 | 59 | testDto = async (req: Request, res: Response, next: NextFunction) => { 60 | try { 61 | const data = { 62 | id: "123", 63 | name: "John Doe", 64 | thiswillalsoberemoved: "This will be removed too", 65 | extraData: { 66 | id: "456", 67 | name: "John Abc", 68 | thiswillberemoved: "This will be removed", 69 | }, 70 | }; 71 | 72 | const accountDto = toAccountDto(data); 73 | 74 | return res.json(accountDto); 75 | } catch (error) { 76 | next(error); 77 | } 78 | }; 79 | 80 | testEventEmitter = async ( 81 | req: Request, 82 | res: Response, 83 | next: NextFunction 84 | ) => { 85 | try { 86 | await eventsService.emitAsync("test.event", "Hello World"); 87 | 88 | return res.json({ 89 | message: "Event emitted.", 90 | }); 91 | } catch (error) { 92 | next(error); 93 | } 94 | }; 95 | 96 | testDependencyInjection = async ( 97 | req: Request, 98 | res: Response, 99 | next: NextFunction 100 | ) => { 101 | try { 102 | const result = testService.example(); 103 | 104 | return res.json({ 105 | message: result, 106 | commissionRate: testConfig.defaultCommissionRate, 107 | }); 108 | } catch (error) { 109 | next(error); 110 | } 111 | }; 112 | 113 | testPdf = async (req: Request, res: Response, next: NextFunction) => { 114 | try { 115 | // Get template 116 | const template = await fs.readFile( 117 | path.join(getAppDir(), "shared", "views", "invoice.ejs"), 118 | "utf-8" 119 | ); 120 | 121 | // Render template 122 | const renderedTemplate = ejs.render(template, {}); 123 | 124 | // Generate PDF 125 | const pdfBuffer = await pdfService.htmlToPdf({ html: renderedTemplate }); 126 | 127 | res.setHeader("Content-Type", "application/pdf"); 128 | res.setHeader("Content-Disposition", "inline; filename=invoice.pdf"); 129 | return res.send(pdfBuffer); 130 | } catch (error) { 131 | next(error); 132 | } 133 | }; 134 | 135 | testComplexUseCase = async ( 136 | req: Request, 137 | res: Response, 138 | next: NextFunction 139 | ) => { 140 | try { 141 | const result = testWriteTextUseCase.execute(); 142 | 143 | return res.json({ 144 | message: result, 145 | }); 146 | } catch (error) { 147 | next(error); 148 | } 149 | }; 150 | 151 | sentry = async (req: Request, res: Response, next: NextFunction) => { 152 | try { 153 | throw new Error("My first Sentry error!"); 154 | } catch (error) { 155 | next(error); 156 | } 157 | }; 158 | } 159 | 160 | export const testController = new TestController(); 161 | -------------------------------------------------------------------------------- /backend/src/modules/test/dtos/account.dto.ts: -------------------------------------------------------------------------------- 1 | export const toAccountDto = (account) => { 2 | return { 3 | id: account.id, 4 | name: account.name, 5 | extraData: { 6 | id: account.extraData.id, 7 | name: account.extraData.name, 8 | }, 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /backend/src/modules/test/routes/test.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { testController } from "../controllers/test.controller"; 3 | import { strictThrottler } from "@/common/throttlers/strict.throttler"; 4 | 5 | export const testRoutes = Router(); 6 | 7 | /** 8 | * @swagger 9 | * /api/tests/bad-request: 10 | * get: 11 | * summary: Test bad request 12 | * description: Test a bad request error thrown from the server. 13 | * tags: [Tests] 14 | * responses: 15 | * '200': 16 | * description: OK 17 | */ 18 | testRoutes.get("/tests/bad-request", testController.testBadRequest); 19 | 20 | /** 21 | * @swagger 22 | * /api/tests/strict-throttler: 23 | * get: 24 | * summary: Test strict throttler 25 | * description: Test a strict throttler that will block the IP Address after X attempts. 26 | * tags: [Tests] 27 | * responses: 28 | * '200': 29 | * description: OK 30 | */ 31 | testRoutes.get( 32 | "/tests/strict-throttler", 33 | strictThrottler, 34 | testController.testDto 35 | ); 36 | 37 | /** 38 | * @swagger 39 | * /api/tests/queue-launch: 40 | * get: 41 | * summary: Start a new queue 42 | * description: Start a new sandboxed queue with BullMQ. 43 | * tags: [Tests] 44 | * responses: 45 | * '200': 46 | * description: OK 47 | */ 48 | testRoutes.get("/tests/queue-launch", testController.testQueueLaunch); 49 | 50 | /** 51 | * @swagger 52 | * /api/tests/zod: 53 | * post: 54 | * summary: Test Zod 55 | * description: Test Zod validation. 56 | * tags: [Tests] 57 | * requestBody: 58 | * required: true 59 | * content: 60 | * application/json: 61 | * schema: 62 | * type: object 63 | * properties: 64 | * name: 65 | * type: string 66 | * bookings: 67 | * type: array 68 | * items: 69 | * type: object 70 | * properties: 71 | * id: 72 | * type: string 73 | * name: 74 | * type: string 75 | * phoneNumber: 76 | * type: string 77 | * responses: 78 | * '200': 79 | * description: OK 80 | */ 81 | testRoutes.post("/tests/zod", testController.testZod); 82 | 83 | /** 84 | * @swagger 85 | * /api/tests/dto: 86 | * get: 87 | * summary: Test DTO 88 | * description: Test DTO. 89 | * tags: [Tests] 90 | * responses: 91 | * '200': 92 | * description: OK 93 | */ 94 | testRoutes.get("/tests/dto", testController.testDto); 95 | 96 | /** 97 | * @swagger 98 | * /api/tests/event-emitter: 99 | * get: 100 | * summary: Test event emitter 101 | * description: Test event emitter. 102 | * tags: [Tests] 103 | * responses: 104 | * '200': 105 | * description: OK 106 | */ 107 | testRoutes.get("/tests/event-emitter", testController.testEventEmitter); 108 | 109 | /** 110 | * @swagger 111 | * /api/tests/dependency-injection: 112 | * get: 113 | * summary: Test dependency injection 114 | * description: Inject a service from another module. This is a simple homemade dependency injection. Please check common/lib/services.ts for more details. 115 | * tags: [Tests] 116 | * responses: 117 | * '200': 118 | * description: OK 119 | */ 120 | testRoutes.get( 121 | "/tests/dependency-injection", 122 | testController.testDependencyInjection 123 | ); 124 | 125 | /** 126 | * @swagger 127 | * /api/tests/pdf: 128 | * get: 129 | * summary: Test PDF 130 | * description: Test PDF. 131 | * tags: [Tests] 132 | * responses: 133 | * '200': 134 | * description: OK 135 | */ 136 | testRoutes.get("/tests/pdf", testController.testPdf); 137 | 138 | /** 139 | * @swagger 140 | * /api/tests/complex-use-case: 141 | * get: 142 | * summary: Test complex use case 143 | * description: Test complex use case. 144 | * tags: [Tests] 145 | * responses: 146 | * '200': 147 | * description: OK 148 | */ 149 | testRoutes.get("/tests/complex-use-case", testController.testComplexUseCase); 150 | 151 | /** 152 | * @swagger 153 | * /api/tests/sentry: 154 | * get: 155 | * summary: Test Sentry 156 | * description: Test Sentry. 157 | * tags: [Tests] 158 | * responses: 159 | * '200': 160 | * description: OK 161 | */ 162 | testRoutes.get("/tests/sentry", testController.sentry); 163 | -------------------------------------------------------------------------------- /backend/src/modules/test/test.config.ts: -------------------------------------------------------------------------------- 1 | export const testConfig = { 2 | defaultCommissionRate: 25, 3 | defaultCommissionTaxRate: 20, 4 | }; 5 | -------------------------------------------------------------------------------- /backend/src/modules/test/test.listener.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@/common/utils/logger"; 2 | import { eventsService } from "@/common/events/events.service"; 3 | 4 | const logger = new Logger("test-event-listener"); 5 | 6 | eventsService.on("test.event", async (data) => { 7 | logger.info("test-event listener received data !", { data }); 8 | }); 9 | -------------------------------------------------------------------------------- /backend/src/modules/test/test.service.ts: -------------------------------------------------------------------------------- 1 | import { testAuthorService } from "@/modules/test-author/test-author.service"; 2 | 3 | export class TestService { 4 | /** 5 | * Example of a service that uses another service from another module (Dependency Injection) 6 | */ 7 | example = () => { 8 | const result = testAuthorService.sayHello(); 9 | return `message received from another module's injected service: ${result}`; 10 | }; 11 | } 12 | 13 | export const testService = new TestService(); 14 | -------------------------------------------------------------------------------- /backend/src/modules/test/tests/test.controller.test.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import { bootstrap } from "@/app"; 3 | import { Express } from "express"; 4 | import { initializeTestDb } from "@/common/test/jest-initialize-db"; 5 | 6 | let app: Express; 7 | 8 | beforeAll(async () => { 9 | await initializeTestDb(); 10 | 11 | app = await bootstrap(); 12 | }); 13 | 14 | describe("AdminCartController", () => { 15 | describe("GET /api/tests/bad-request", () => { 16 | it("returns a status code 400 if the bad request test is successful", async () => { 17 | const response = await request(app).get("/api/tests/bad-request"); 18 | 19 | expect(response.status).toEqual(400); 20 | expect(response.body).toEqual({ 21 | message: "An error occurred.", 22 | code: "TEST_BAD_REQUEST", 23 | }); 24 | }); 25 | }); 26 | 27 | describe("GET /api/tests/dependency-injection", () => { 28 | it("returns a status code 200 if the dependency injection is successful", async () => { 29 | const response = await request(app).get( 30 | "/api/tests/dependency-injection" 31 | ); 32 | 33 | expect(response.status).toEqual(200); 34 | expect(response.body).toEqual({ 35 | commissionRate: 25, 36 | message: 37 | "message received from another module's injected service: Hello World", 38 | }); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /backend/src/modules/test/validators/test.validators.ts: -------------------------------------------------------------------------------- 1 | import { phoneNumberTransformer } from "@/common/transformers/phone-number.transformer"; 2 | import { phoneNumberValidator } from "@/common/validators/phone-number.validator"; 3 | import { z } from "zod"; 4 | 5 | export const updateAccountValidator = z.object({ 6 | body: z.object({ 7 | name: z.string().min(1), 8 | bookings: z.array( 9 | z.object({ 10 | id: z.string().min(1), 11 | name: z.string().min(1), 12 | }) 13 | ), 14 | phoneNumber: z 15 | .string() 16 | .pipe(phoneNumberValidator) 17 | .pipe(phoneNumberTransformer), 18 | }), 19 | }); 20 | -------------------------------------------------------------------------------- /backend/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { meRoutes } from "@/modules/me/routes/me.routes"; 3 | import { testRoutes } from "@/modules/test/routes/test.routes"; 4 | import { mediaRoutes } from "@/modules/media/routes/media.routes"; 5 | import { authRoutes } from "@/modules/auth/routes/auth.routes"; 6 | 7 | export const apiRoutes = Router(); 8 | 9 | // -- Common 10 | apiRoutes.use(mediaRoutes); 11 | 12 | // -- Auth 13 | apiRoutes.use(meRoutes); 14 | apiRoutes.use(authRoutes); 15 | 16 | // -- Business 17 | apiRoutes.use(testRoutes); 18 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import { bootstrap } from "./app"; 2 | import { Logger } from "./common/utils/logger"; 3 | import { env } from "./config"; 4 | const logger = new Logger("server"); 5 | 6 | const setup = async () => { 7 | try { 8 | const app = await bootstrap(); 9 | const PORT = env.APP_PORT; 10 | 11 | // Start server 12 | app.listen(PORT, () => { 13 | // Log server ready 14 | logger.info(`🚀 Server ready on port: ${PORT}`); 15 | }); 16 | } catch (error) { 17 | logger.error("An error occured on the server.", { 18 | error: error?.message, 19 | stack: error?.stack, 20 | }); 21 | process.exit(1); 22 | } 23 | }; 24 | 25 | setup(); 26 | -------------------------------------------------------------------------------- /backend/src/static/assets/css/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /backend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/common/views/**/*.{ejs,html}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "commonjs", 5 | 6 | // -- Base 7 | "rootDir": ".", // set root dir for source files (this is the path we will start building from in the output dir) 8 | "outDir": "./dist", // set output dir for build files 9 | 10 | // -- Aliases 11 | "baseUrl": ".", // set base url for paths 12 | "paths": { 13 | "@/*": ["./src/*"] 14 | }, 15 | 16 | // -- Misc 17 | "removeComments": true, 18 | "emitDecoratorMetadata": true, 19 | "experimentalDecorators": true, 20 | "allowSyntheticDefaultImports": true, 21 | "sourceMap": true, // help with debugging by mapping the compiled code back to the original source 22 | "incremental": true, // incremental build to speed up the build process 23 | "skipLibCheck": true, // ignore node_modules 24 | "strictNullChecks": true, // allow null checks for Zod infers to work 25 | "noImplicitAny": false, // allow any type 26 | "strictBindCallApply": false, // allow bind, call and apply to work 27 | "resolveJsonModule": true, // allow import json 28 | "esModuleInterop": true, // allow import es modules 29 | "allowJs": true, // allow .js files 30 | 31 | // Set `sourceRoot` to "/" to strip the build path prefix from generated source code references. 32 | // This improves issue grouping in Sentry. 33 | "sourceRoot": "/", 34 | 35 | // -- Types 36 | "declaration": true, 37 | "declarationMap": true, 38 | "types": ["node", "express", "jest"], 39 | "typeRoots": ["./node_modules/@types", "./src/common/types"] 40 | }, 41 | "include": ["src/**/*", "prisma/**/*"], 42 | "exclude": ["node_modules", "dist"] 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # =================================================================================================================================================================================== 3 | backend: 4 | command: npm run start:dev 5 | environment: 6 | - NODE_ENV=development 7 | volumes: 8 | - ./backend:/usr/src/app 9 | - /usr/src/app/node_modules 10 | restart: "on-failure" 11 | # =================================================================================================================================================================================== 12 | nginx: 13 | restart: "on-failure" 14 | # =================================================================================================================================================================================== 15 | frontend: 16 | command: npm run dev 17 | # command: /bin/sh -c "npm run build && npm run start" 18 | environment: 19 | - NODE_ENV=development 20 | volumes: 21 | - ./frontend:/usr/src/app 22 | - /usr/src/app/node_modules 23 | restart: "on-failure" 24 | # =================================================================================================================================================================================== 25 | redis-commander: 26 | image: ghcr.io/joeferner/redis-commander:latest 27 | environment: 28 | - REDIS_HOST=${APP_REDIS_HOST} 29 | - REDIS_PORT=${APP_REDIS_PORT} 30 | - REDIS_PASSWORD=GhostLexly.7 31 | ports: 32 | - "8081:8081" 33 | # =================================================================================================================================================================================== 34 | postgres: 35 | image: postgres:17.2 36 | ports: 37 | - "5432:5432" 38 | environment: 39 | - POSTGRES_USER=ghostlexly 40 | - POSTGRES_PASSWORD=password 41 | - POSTGRES_DB=${PROJECT_NAME} 42 | volumes: 43 | - ./postgres:/var/lib/postgresql/data 44 | # =================================================================================================================================================================================== 45 | # mysql_backup: 46 | # image: databack/mysql-backup:8b7f6338d7af6a008d20c986b862a99310eb237d 47 | # volumes: 48 | # - ./mysql-backups:/db:z 49 | # environment: 50 | # - DB_DUMP_TARGET=/db 51 | # - DB_NAMES=${PROJECT_NAME} 52 | # - DB_USER=root 53 | # - DB_PASS=hnLcqbLjGfruHlHN 54 | # - DB_SERVER=mysql 55 | # - DB_PORT=3306 56 | # - DB_DUMP_FREQ=360 # In minutes 57 | # - DB_DUMP_BEGIN=0000 # HHMM OR +10 to start 10 minutes after run 58 | # - NICE=true 59 | # user: "0" 60 | # depends_on: 61 | # - mysql 62 | # restart: always 63 | # =================================================================================================================================================================================== 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # =================================================================================================================================================================================== 3 | backend: 4 | image: fenrisshq/terracapital:backend 5 | build: 6 | context: ./backend 7 | environment: 8 | - NODE_ENV=production 9 | - FORCE_COLOR=1 10 | - DISPLAY=${X11_DISPLAY} 11 | - APP_PORT=${APP_PORT} 12 | - APP_BASE_URL=${APP_BASE_URL} 13 | - APP_DATABASE_CONNECTION_URL=${APP_DATABASE_CONNECTION_URL} 14 | - APP_REDIS_HOST=${APP_REDIS_HOST} 15 | - APP_REDIS_PORT=${APP_REDIS_PORT} 16 | - APP_JWT_SECRET_KEY=${APP_JWT_SECRET_KEY} 17 | - APP_JWT_PUBLIC_KEY=${APP_JWT_PUBLIC_KEY} 18 | - API_S3_ENDPOINT=${API_S3_ENDPOINT} 19 | - API_S3_ACCESS_KEY=${API_S3_ACCESS_KEY} 20 | - API_S3_SECRET_KEY=${API_S3_SECRET_KEY} 21 | - API_S3_BUCKET=${API_S3_BUCKET} 22 | - API_GOOGLE_CLIENT_ID=${API_GOOGLE_CLIENT_ID} 23 | - API_GOOGLE_CLIENT_SECRET=${API_GOOGLE_CLIENT_SECRET} 24 | - APP_PLAYWRIGHT_HEADLESS=${APP_PLAYWRIGHT_HEADLESS} 25 | - SENTRY_AUTH_TOKEN=${API_SENTRY_AUTH_TOKEN} 26 | volumes: 27 | - ./backend/logs:/usr/src/app/logs 28 | depends_on: 29 | - redis 30 | tty: true 31 | restart: always 32 | # =================================================================================================================================================================================== 33 | frontend: 34 | image: fenrisshq/terracapital:frontend 35 | build: 36 | context: ./frontend 37 | environment: 38 | - NODE_ENV=production 39 | restart: always 40 | # =================================================================================================================================================================================== 41 | nginx: 42 | image: fenrisshq/terracapital:nginx 43 | build: 44 | context: ./nginx 45 | ports: 46 | - mode: host 47 | protocol: tcp 48 | published: 80 49 | target: 80 50 | - mode: host 51 | protocol: tcp 52 | published: 443 53 | target: 443 54 | volumes: 55 | - ./nginx/logs:/var/log/nginx:z 56 | depends_on: 57 | - backend 58 | - frontend 59 | restart: always 60 | # =================================================================================================================================================================================== 61 | redis: 62 | image: bitnami/redis:7.0.8 63 | command: /opt/bitnami/scripts/redis/run.sh --maxmemory-policy noeviction 64 | environment: 65 | - ALLOW_EMPTY_PASSWORD=yes 66 | volumes: 67 | - redis_data:/bitnami/redis/data 68 | restart: always 69 | 70 | volumes: 71 | redis_data: 72 | -------------------------------------------------------------------------------- /frontend/.cursorrules: -------------------------------------------------------------------------------- 1 | You are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind. 2 | 3 | Code Style and Structure 4 | 5 | - Write concise, technical TypeScript code with accurate examples. 6 | - Use functional and declarative programming patterns; avoid classes. 7 | - Prefer iteration and modularization over code duplication. 8 | - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError). 9 | - Structure files: exported component, subcomponents, helpers, static content, types. 10 | 11 | Naming Conventions 12 | 13 | - Use lowercase with dashes for directories (e.g., components/auth-wizard). 14 | - Favor named exports for components. 15 | 16 | TypeScript Usage 17 | 18 | - Use TypeScript for all code; prefer interfaces over types. 19 | - Avoid enums; use maps instead. 20 | - Use functional components with TypeScript interfaces. 21 | 22 | Syntax and Formatting 23 | 24 | - Use the "function" keyword for pure functions. 25 | - Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements. 26 | - Use declarative JSX. 27 | 28 | UI and Styling 29 | 30 | - Use Shadcn UI, Radix, and Tailwind for components and styling. 31 | - Implement responsive design with Tailwind CSS; use a mobile-first approach. 32 | 33 | Performance Optimization 34 | 35 | - Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC). 36 | - Wrap client components in Suspense with fallback. 37 | - Use dynamic loading for non-critical components. 38 | - Optimize images: use WebP format, include size data, implement lazy loading. 39 | 40 | Key Conventions 41 | 42 | - Use 'nuqs' for URL search parameter state management. 43 | - Optimize Web Vitals (LCP, CLS, FID). 44 | - Limit 'use client': 45 | - Favor server components and Next.js SSR. 46 | - Use only for Web API access in small components. 47 | - Avoid for data fetching or state management. 48 | 49 | Follow Next.js docs for Data Fetching, Rendering, and Routing. 50 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # -- BUILDER 2 | FROM node:22.9.0-bullseye-slim AS builder 3 | WORKDIR /usr/src/app 4 | 5 | # Install dependencies 6 | COPY . . 7 | RUN npm install 8 | 9 | # Build project 10 | RUN npm run build 11 | 12 | # -- RUNNER 13 | FROM node:22.9.0-bullseye-slim AS base 14 | WORKDIR /usr/src/app 15 | ENV NODE_ENV=production 16 | ENV NODE_TLS_REJECT_UNAUTHORIZED=0 17 | 18 | # Update apt and install security updates 19 | RUN apt update && \ 20 | apt upgrade -y && \ 21 | apt install -y ca-certificates && \ 22 | apt clean 23 | 24 | # Install production-only dependencies (this will ignore devDependencies because NODE_ENV is set to production) 25 | COPY ./package.json ./ 26 | COPY ./package-lock.json ./ 27 | COPY --from=builder /usr/src/app/node_modules ./node_modules 28 | 29 | # Copy project specific files 30 | COPY --from=builder /usr/src/app/.next ./.next 31 | COPY ./next.config.ts ./next.config.ts 32 | COPY ./public ./public 33 | 34 | ENV NODE_ENV=production 35 | CMD npm run start -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^19.0.0", 13 | "react-dom": "^19.0.0", 14 | "next": "15.1.4" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^19", 20 | "@types/react-dom": "^19", 21 | "postcss": "^8", 22 | "tailwindcss": "^3.4.1", 23 | "eslint": "^9", 24 | "eslint-config-next": "15.1.4", 25 | "@eslint/eslintrc": "^3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ghostlexly/ultimate-typescript-starter-kit/d2a874c2fd524dff51be4e3e72281e59264d866e/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 | Next.js logo 15 |
    16 |
  1. 17 | Get started by editing{" "} 18 | 19 | src/app/page.tsx 20 | 21 | . 22 |
  2. 23 |
  3. Save and see your changes instantly.
  4. 24 |
25 | 26 | 51 |
52 | 99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /jakefile.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require("child_process"); 2 | 3 | desc("This is the default task."); 4 | task("default", function () { 5 | jake.showAllTaskDescriptions(); 6 | }); 7 | 8 | desc("Start the development environment."); 9 | task("dev", async () => { 10 | await asyncSpawn( 11 | "docker compose -f docker-compose.yml -f docker-compose.dev.yml up --renew-anon-volumes" 12 | ); 13 | }); 14 | 15 | // -- PRISMA -- 16 | namespace("prisma", function () { 17 | desc("Generate Prisma Client files."); 18 | task("g", async () => { 19 | await asyncSpawn("cd backend && npx prisma generate"); // Generate Prisma Client files on local 20 | await asyncSpawn("docker compose exec backend npx prisma generate"); // Generate Prisma Client files on docker containr 21 | await asyncSpawn("docker compose restart backend"); 22 | }); 23 | 24 | // Migrations 25 | namespace("m", function () { 26 | desc("Automatically generate new Prisma migration."); 27 | task("g", async () => { 28 | await asyncSpawn( 29 | "docker compose exec backend npx prisma migrate dev --create-only" 30 | ); 31 | }); 32 | 33 | desc("Apply the latest Prisma migrations."); 34 | task("m", async () => { 35 | await asyncSpawn("docker compose exec backend npx prisma migrate deploy"); 36 | await asyncSpawn("cd backend && npx prisma generate"); // Generate Prisma Client files on local 37 | await asyncSpawn("docker compose exec backend npx prisma generate"); // Generate Prisma Client files on docker containr 38 | await asyncSpawn("docker compose restart backend"); 39 | }); 40 | 41 | desc( 42 | "Check if my database is up to date with my schema file or if i need to create a migration." 43 | ); 44 | task("diff", async () => { 45 | await asyncSpawn( 46 | "docker compose exec backend npx prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script" 47 | ); 48 | }); 49 | }); 50 | }); 51 | 52 | desc("Run CLI commands in the backend container with arguments."); 53 | task("cli", async () => { 54 | // Get raw arguments without processing/joining them 55 | const rawArgs = process.argv.slice(3); 56 | 57 | // Process arguments to preserve quotes 58 | const processedArgs = rawArgs 59 | .map((arg) => { 60 | // If argument contains spaces, wrap it in quotes 61 | return arg.includes(" ") ? `"${arg}"` : arg; 62 | }) 63 | .join(" "); 64 | 65 | const command = `docker compose exec backend npm run cli -- ${processedArgs}`; 66 | await asyncSpawn(command); 67 | // If asyncSpawn completes without throwing, the command was successfully executed. 68 | // Exit to prevent Jake from interpreting subsequent arguments as tasks. 69 | log("CLI command processed successfully. Exiting Jake."); 70 | process.exit(0); 71 | }); 72 | 73 | // ---------------------------------------------- 74 | // Helper functions 75 | // ---------------------------------------------- 76 | const log = (message) => { 77 | console.log(`\x1b[32m[Jake] - ${message} \x1b[0m`); 78 | }; 79 | 80 | const asyncSpawn = (command, options) => { 81 | return new Promise((resolve, reject) => { 82 | const childProcess = spawn(command, { 83 | shell: true, 84 | stdio: ["inherit", "inherit", "inherit"], 85 | ...options, 86 | }); 87 | 88 | childProcess.on("exit", (code) => { 89 | if (code !== 0) { 90 | reject(`Child process exited with code ${code}.`); 91 | } 92 | 93 | log(`Child process exited with code ${code}.`); 94 | resolve(); 95 | }); 96 | 97 | childProcess.on("error", (error) => { 98 | reject(`Error on process spawn: ${error.message}.`); 99 | }); 100 | 101 | // Handle the SIGINT signal (Ctrl+C) to stop the child process before exiting 102 | process.on("SIGINT", () => { 103 | childProcess.kill(); 104 | }); 105 | }); 106 | }; 107 | -------------------------------------------------------------------------------- /nginx/.gitignore: -------------------------------------------------------------------------------- 1 | /letsencrypt -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.21.1-alpine 2 | 3 | RUN apk update && apk add bash shadow certbot openssl 4 | 5 | RUN rm -rf /etc/nginx/conf.d/* 6 | COPY ./conf /etc/nginx/conf.d 7 | COPY ./nginx.conf /etc/nginx/nginx.conf -------------------------------------------------------------------------------- /nginx/conf/common.conf: -------------------------------------------------------------------------------- 1 | # -- don't send the nginx version number in error pages and Server header 2 | server_tokens off; 3 | 4 | # -- expires 5 | # initialize expires regex if/else by $uri$args ($uri = url, $args = url queries) 6 | # default should be 0d to prevent caching php files ! (important) 7 | map $uri$args $expires_by_uri { 8 | ~\.(png|jpg|jpeg|gif|jfif|webp) 365d; 9 | ~\.(js|css|pdf|html|swf) 365d; 10 | ~\/api\/media\/ 365d; 11 | default off; 12 | } 13 | expires $expires_by_uri; 14 | 15 | # -- docker dns resolver, cache 5s 16 | resolver 127.0.0.11 valid=5s; 17 | 18 | # -- File upload 19 | # You should also change /etc/php.ini file, set upload_max_filesize=10M; and post_max_size=10M; 20 | client_max_body_size 100M; 21 | client_body_buffer_size 100M; 22 | 23 | # -- performances 24 | client_body_timeout 100; 25 | client_header_timeout 10; 26 | send_timeout 60; 27 | keepalive_requests 700; 28 | open_file_cache max=2000 inactive=5m; 29 | open_file_cache_valid 2m; 30 | open_file_cache_min_uses 2; 31 | open_file_cache_errors on; 32 | tcp_nopush on; 33 | tcp_nodelay on; 34 | 35 | # -- gzip 36 | gzip on; 37 | gzip_vary on; 38 | gzip_min_length 10240; 39 | gzip_proxied expired no-cache no-store private auth; 40 | gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/javascript application/xml; 41 | gzip_disable "MSIE [1-6]\."; 42 | 43 | # -- proxy headers 44 | # proxy_set_header are the headers sent to the server only. The client doesn't see it. 45 | proxy_set_header Host $host; 46 | proxy_set_header X-Forwarded-Proto $scheme; 47 | proxy_ignore_headers Cache-Control; 48 | 49 | # -- proxy settings 50 | proxy_http_version 1.1; 51 | proxy_read_timeout 100; 52 | proxy_connect_timeout 100; 53 | 54 | # -- headers 55 | add_header Pragma public; 56 | 57 | #add_header X-Cache-Status $upstream_cache_status; # show if its hitten the nginx proxy_cache 58 | add_header X-Powered-By "LUNISOFT - contact@lunisoft.fr" always; 59 | 60 | # -- security 61 | # config to enable HSTS(HTTP Strict Transport Security) https://developer.mozilla.org/en-US/docs/Security/HTTP_Strict_Transport_Security 62 | # to avoid ssl stripping https://en.wikipedia.org/wiki/SSL_stripping#SSL_stripping 63 | add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload"; 64 | 65 | add_header X-Frame-Options SAMEORIGIN; 66 | add_header X-Content-Type-Options nosniff; 67 | add_header X-XSS-Protection "1; mode=block"; 68 | 69 | # CORS Policy 70 | #add_header 'Access-Control-Allow-Origin' '*' always; 71 | #add_header "Access-Control-Allow-Headers" '*' always; -------------------------------------------------------------------------------- /nginx/conf/default.conf: -------------------------------------------------------------------------------- 1 | # main server 2 | server { 3 | listen 80; 4 | server_name _; 5 | root /var/www/html; 6 | 7 | # -- common proxy settings 8 | proxy_http_version 1.1; 9 | proxy_set_header Upgrade $http_upgrade; 10 | proxy_set_header Connection "upgrade"; 11 | proxy_set_header Host $host; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header X-Forwarded-Host $host; 15 | proxy_set_header X-Forwarded-Port $server_port; 16 | 17 | # -- errors 5xx 18 | error_page 500 502 503 504 /50x.html; 19 | 20 | # -- www to normal host redirect 21 | if ($host ~* "^(www.)(.*)") { 22 | return 302 https://$2$request_uri; 23 | } 24 | 25 | # -- frontend 26 | location / { 27 | proxy_pass http://nodejs_servers; 28 | } 29 | 30 | # -- api 31 | location /api/ { 32 | proxy_pass http://backend_servers; 33 | } 34 | 35 | # -- static 36 | location /static/ { 37 | proxy_pass http://backend_servers; 38 | } 39 | 40 | # -- hot-reload support for nextjs 41 | location /_next/webpack-hmr { 42 | proxy_pass http://nodejs_servers; 43 | } 44 | } -------------------------------------------------------------------------------- /nginx/conf/servers.conf: -------------------------------------------------------------------------------- 1 | # -- Load Balanced Servers -- 2 | upstream nodejs_servers { 3 | server frontend:3000; 4 | } 5 | 6 | upstream backend_servers { 7 | server backend:3000; 8 | } -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | user nginx; 3 | worker_processes auto; 4 | 5 | error_log /var/log/nginx/error.log notice; 6 | pid /var/run/nginx.pid; 7 | 8 | 9 | events { 10 | worker_connections 2048; 11 | } 12 | 13 | 14 | http { 15 | include /etc/nginx/mime.types; 16 | default_type application/octet-stream; 17 | 18 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 19 | '$status $body_bytes_sent "$http_referer" ' 20 | '"$http_user_agent" "$http_x_forwarded_for"'; 21 | 22 | access_log /var/log/nginx/access.log main; 23 | 24 | sendfile on; 25 | #tcp_nopush on; 26 | 27 | keepalive_timeout 65; 28 | 29 | #gzip on; 30 | 31 | include /etc/nginx/conf.d/*.conf; 32 | } 33 | --------------------------------------------------------------------------------