├── .env.example ├── .github └── workflows │ ├── build.yml │ └── ci.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── backend ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── nest-cli.json ├── package.json ├── prisma │ ├── migrations │ │ ├── 20240414130439_init_prisma │ │ │ └── migration.sql │ │ ├── 20240512104520_add_offers_and_transactions │ │ │ └── migration.sql │ │ ├── 20240514173402_unique_user_offer │ │ │ └── migration.sql │ │ ├── 20240528171404_messages │ │ │ └── migration.sql │ │ ├── 20240601114100_add_offer_in_message │ │ │ └── migration.sql │ │ ├── 20250624071110_add_images │ │ │ └── migration.sql │ │ ├── 20250916115632_add_stripe_connect │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma ├── src │ ├── app.module.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ ├── cookie-serializer.ts │ │ ├── exception.filter.ts │ │ ├── local-auth.guard.ts │ │ ├── local.strategy.ts │ │ └── redirected-error.exception.ts │ ├── aws │ │ └── aws-s3.service.ts │ ├── main.ts │ ├── prisma │ │ └── prisma.service.ts │ ├── remix │ │ ├── remix.controller.ts │ │ └── remix.service.ts │ ├── scripts │ │ └── seed-offers.ts │ └── stripe │ │ ├── stripe.controller.ts │ │ └── stripe.service.ts ├── start.sh ├── tsconfig.build.json └── tsconfig.json ├── biome.json ├── cache └── dump.rdb ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.redis.yml ├── frontend ├── .dockerignore ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app │ ├── components │ │ ├── Chatbox.tsx │ │ ├── Footer.tsx │ │ ├── Navbar.tsx │ │ ├── forms.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── pagination.tsx │ │ │ ├── select.tsx │ │ │ └── textarea.tsx │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── global.css │ ├── lib │ │ └── utils.ts │ ├── root.tsx │ ├── routes │ │ ├── _assets │ │ │ └── logo-coup-de-pouce-dark.png │ │ ├── _index.tsx │ │ ├── _public+ │ │ │ ├── _layout.tsx │ │ │ ├── login.tsx │ │ │ ├── my-services.$offerId.tsx │ │ │ ├── my-services.index.tsx │ │ │ ├── prestataires.$prestataireId.tsx │ │ │ ├── prestataires.index.tsx │ │ │ ├── profile.tsx │ │ │ ├── register.tsx │ │ │ ├── stripe.dashboard.tsx │ │ │ ├── stripe.onboarding.tsx │ │ │ ├── stripe.refresh.tsx │ │ │ ├── stripe.return.tsx │ │ │ ├── transactions.$transactionId.tsx │ │ │ └── transactions.index.tsx │ │ └── offers.$offerId.tsx │ └── server │ │ ├── auth.server.ts │ │ ├── offers.server.ts │ │ ├── profile.server.ts │ │ ├── providers.server.ts │ │ ├── stripe.server.ts │ │ └── transactions.server.ts ├── components.json ├── env.d.ts ├── index.cjs ├── index.d.cts ├── package.json ├── postcss.config.cjs ├── public │ └── favicon.ico ├── tailwind.config.cjs ├── tsconfig.json ├── vite.config.ts ├── vite.config.ts.timestamp-1751794841752-fdfb69122621c.mjs └── vite.config.ts.timestamp-1758024100248-611b4875cc2f78.mjs ├── package-lock.json ├── package.json ├── packages ├── eslint-config │ ├── base.js │ └── package.json └── typescript-config │ ├── base.json │ └── package.json └── turbo.json /.env.example: -------------------------------------------------------------------------------- 1 | DOCKERHUB_TOKEN= 2 | DOCKERHUB_USERNAME= 3 | 4 | REDIS_URL="" 5 | SESSION_SECRET="" 6 | DATABASE_URL="" 7 | 8 | 9 | PORT=3000 10 | AWS_ACCESS_KEY="" 11 | AWS_SECRET="" 12 | AWS_REGION="" 13 | AWS_BUCKET_NAME="" 14 | 15 | PRISMA_OPTIMIZE_API_KEY="" 16 | 17 | STRIPE_SECRET_KEY="" 18 | STRIPE_WEBHOOK_SECRET="" -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 🐳 Build And Push Docker Image 2 | on: 3 | workflow_call: 4 | inputs: 5 | tag: 6 | type: string 7 | description: The tag to push to the Docker registry. 8 | # required: true 9 | # default: latest 10 | 11 | jobs: 12 | build: 13 | name: 🐳 Build 14 | # only build/deploy main branch on pushes 15 | # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 16 | if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4.1.1 21 | 22 | - name: 🧑‍💻 Login to Docker Hub 23 | uses: docker/login-action@v3.0.0 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | logout: true 28 | 29 | - name: 🐳 Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3.0.0 31 | 32 | # Setup cache 33 | - name: ⚡️ Cache Docker layers 34 | uses: actions/cache@v4.2.3 35 | with: 36 | path: /tmp/.buildx-cache 37 | key: ${{ runner.os }}-buildx-${{ github.sha }}-${{ github.ref_name }} 38 | restore-keys: | 39 | ${{ runner.os }}-buildx- 40 | - name: 🐳 Build Production Image 41 | if: ${{ github.ref == 'refs/heads/main' }} 42 | uses: docker/build-push-action@v5.1.0 43 | with: 44 | context: . 45 | push: true 46 | tags: varkoff/nestjs-remix-monorepo:production 47 | cache-from: type=local,src=/tmp/.buildx-cache 48 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new 49 | 50 | # - name: 🐳 Build Staging Image 51 | # if: ${{ github.ref == 'refs/heads/dev' }} 52 | # uses: docker/build-push-action@v5.1.0 53 | # with: 54 | # context: . 55 | # push: true 56 | # tags: varkoff/nestjs-remix-monorepo:latest 57 | 58 | # cache-from: type=local,src=/tmp/.buildx-cache 59 | # cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new 60 | 61 | # This ugly bit is necessary if you don't want your cache to grow forever 62 | # till it hits GitHub's limit of 5GB. 63 | # Temp fix 64 | # https://github.com/docker/build-push-action/issues/252 65 | # https://github.com/moby/buildkit/issues/1896 66 | - name: 🚚 Move cache 67 | run: | 68 | rm -rf /tmp/.buildx-cache 69 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: {} 8 | 9 | permissions: 10 | actions: write 11 | contents: read 12 | 13 | jobs: 14 | lint: 15 | name: ⬣ ESLint 16 | runs-on: ubuntu-latest 17 | env: 18 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 19 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 20 | TURBO_REMOTE_ONLY: true 21 | 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 2 27 | 28 | - name: Setup Node.js environment 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: 20 32 | cache: 'npm' 33 | 34 | - name: Install dependencies 35 | run: npm install 36 | 37 | - name: Build Application 38 | run: npm run build 39 | 40 | - name: 🔬 Lint 41 | run: npm run lint 42 | 43 | typecheck: 44 | name: ʦ TypeScript 45 | runs-on: ubuntu-latest 46 | env: 47 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 48 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 49 | TURBO_REMOTE_ONLY: true 50 | 51 | steps: 52 | - name: Check out code 53 | uses: actions/checkout@v4 54 | with: 55 | fetch-depth: 2 56 | 57 | - name: Setup Node.js environment 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: 20 61 | cache: 'npm' 62 | 63 | - name: Install dependencies 64 | run: npm install 65 | 66 | - name: Build Application 67 | run: npm run build 68 | 69 | - name: 🔎 Type check 70 | run: npm run typecheck --if-present 71 | 72 | build: 73 | name: 🐳 build 74 | uses: ./.github/workflows/build.yml 75 | secrets: inherit 76 | 77 | deploy: 78 | name: 🚀 Deploy 79 | runs-on: [self-hosted] 80 | needs: [build, lint, typecheck] 81 | # needs: [build] 82 | # only build/deploy main branch on pushes 83 | # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 84 | if: ${{ (github.ref == 'refs/heads/main') && github.event_name == 'push' }} 85 | env: 86 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 87 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 88 | TURBO_REMOTE_ONLY: true 89 | 90 | steps: 91 | - name: Cache node modules 92 | uses: actions/cache@v4.2.3 93 | with: 94 | path: ~/.npm 95 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 96 | restore-keys: | 97 | ${{ runner.os }}-node- 98 | 99 | - name: ⬇️ Checkout repo 100 | uses: actions/checkout@v4.1.1 101 | 102 | - name: Login to Docker Hub 103 | uses: docker/login-action@v2 104 | with: 105 | username: ${{ secrets.DOCKERHUB_USERNAME }} 106 | password: ${{ secrets.DOCKERHUB_TOKEN }} 107 | # - name: 🚀 Run Docker Compose on Staging 108 | # if: ${{ github.ref == 'refs/heads/dev' }} 109 | # env: 110 | # NODE_ENV: staging 111 | # run: | 112 | # docker pull varkoff/nestjs-remix-monorepo:latest 113 | # docker compose -f docker-compose.staging.yaml up -d 114 | # docker system prune --all --volumes --force 115 | 116 | - name: 🚀 Run Docker Compose on Production 117 | if: ${{ github.ref == 'refs/heads/main' }} 118 | env: 119 | NODE_ENV: production 120 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 121 | run: | 122 | docker pull varkoff/nestjs-remix-monorepo:production 123 | docker compose -f docker-compose.prod.yml up -d 124 | docker system prune --all --volumes --force 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .turbo 2 | node_modules 3 | 4 | # compiled output 5 | /dist 6 | /node_modules 7 | /build 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | pnpm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | 41 | # dotenv environment variable files 42 | .env 43 | .env.development.local 44 | .env.test.local 45 | .env.production.local 46 | .env.local 47 | 48 | # temp directory 49 | .temp 50 | .tmp 51 | 52 | # Runtime data 53 | pids 54 | *.pid 55 | *.seed 56 | *.pid.lock 57 | 58 | # Diagnostic reports (https://nodejs.org/api/report.html) 59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 60 | out/ 61 | cache/ 62 | course.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=amd64 node:18-alpine As base 2 | 3 | FROM base AS builder 4 | 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | RUN apk update 8 | # Set working directory 9 | WORKDIR /app 10 | RUN npm install --global turbo 11 | COPY --chown=node:node . . 12 | RUN turbo prune @virgile/backend --docker 13 | 14 | # Add lockfile and package.json's of isolated subworkspace 15 | FROM base AS installer 16 | RUN apk add --no-cache libc6-compat 17 | RUN apk update 18 | WORKDIR /app 19 | 20 | # First install the dependencies (as they change less often) 21 | COPY .gitignore .gitignore 22 | COPY --chown=node:node --from=builder /app/out/json/ . 23 | COPY --chown=node:node --from=builder /app/out/package-lock.json ./package-lock.json 24 | RUN npm install 25 | 26 | # Build the project 27 | COPY --from=builder /app/out/full/ . 28 | COPY turbo.json turbo.json 29 | 30 | # Uncomment and use build args to enable remote caching 31 | ARG TURBO_TEAM 32 | ENV TURBO_TEAM=$TURBO_TEAM 33 | 34 | ARG TURBO_TOKEN 35 | ENV TURBO_TOKEN=$TURBO_TOKEN 36 | ENV TZ=Europe/Paris 37 | ENV NODE_ENV="production" 38 | 39 | ADD backend/prisma backend/prisma 40 | RUN cd backend && npx prisma generate 41 | 42 | RUN npm run build 43 | 44 | FROM base AS runner 45 | WORKDIR /app 46 | 47 | # Don't run production as root 48 | RUN addgroup --system --gid 1001 nodejs 49 | RUN adduser --system --uid 1001 remix-api 50 | USER remix-api 51 | 52 | # ENV TZ=Europe/Paris 53 | # ENV NODE_ENV="production" 54 | 55 | COPY --chown=remix-api:nodejs --from=installer /app/backend/package.json ./backend/package.json 56 | COPY --chown=remix-api:nodejs --from=installer /app/backend/dist ./backend/dist 57 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules ./node_modules 58 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/frontend ./node_modules/@virgile/frontend 59 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/typescript-config ./node_modules/@virgile/typescript-config 60 | COPY --chown=remix-api:nodejs --from=installer /app/node_modules/@virgile/eslint-config ./node_modules/@virgile/eslint-config 61 | COPY --chown=remix-api:nodejs --from=installer /app/backend/prisma ./backend/prisma 62 | 63 | COPY --chown=remix-api:nodejs --from=builder /app/backend/start.sh ./backend/start.sh 64 | 65 | ENTRYPOINT [ "backend/start.sh" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ## 🚀 Tu as aimé cette formation ? Passe à React Router 7 ! 4 | 5 | [![Formation React Router 7](https://algomax.fr/api/image?src=https%3A%2F%2Falgomax-public.s3.eu-west-3.amazonaws.com%2F47b80a89-fd57-41fb-bb91-93bdeecd3e43.png&width=1920&height=1080&fit=cover&position=center&quality=80&compressionLevel=9&contentType=image%2Fwebp)](https://algomax.fr/formation-react-router-7) 6 | 7 | **React Router 7**, c'est Remix réinventé : **plus simple, plus rapide, avec Vite 6**. Toute la puissance du SSR, des loaders et actions, mais sans la complexité. 8 | 9 | ✨ **Formation complète** : 76 leçons, 19h56 de contenu 10 | 💼 **Projets réels** : E-commerce avec Stripe, Better Auth, Prisma, AWS S3 11 | 🎯 **Stack moderne** : TypeScript, Tailwind v4, shadcn-ui, déploiement prod 12 | 13 | ### 🎁 **-65% avec le code REMIXJAM** → [Accéder à la formation](https://algomax.fr/formation-react-router-7) 14 | 15 | --- 16 | 17 | ## Stack Remix with NestJS, Turborepo 18 | 19 | ### 📋 Pré-requis 20 | 21 | - [Node.js via nvm](https://github.com/nvm-sh/nvm#installing-and-updating) (recommandé) 22 | - [Docker & Docker Compose](https://docs.docker.com/get-docker/) 23 | 24 | ### 🏗️ Architecture 25 | 26 | Monorepo Turborepo : NestJS sert le frontend Remix directement. Pas d'API REST séparée, juste le serveur NestJS qui monte Remix. 27 | 28 | ``` 29 | ├── backend/ # NestJS + Prisma + Remix server 30 | ├── frontend/ # Remix app (UI/routes) 31 | └── packages/ # Configs partagées (ESLint, TS) 32 | ``` 33 | 34 | ### 🚀 Setup local 35 | 36 | ```bash 37 | # 1. Redis (sessions) 38 | docker compose -f docker-compose.redis.yml up -d 39 | 40 | # 2. Dépendances 41 | npm install 42 | 43 | # 3. Créer .env à la racine (voir section Variables ci-dessous) 44 | 45 | # 4. Copier .env dans backend/ à chaque modif 46 | cp .env backend/.env 47 | 48 | # 5. DB migrations 49 | cd backend 50 | npx prisma migrate dev 51 | npx prisma generate 52 | 53 | # 6. Lancer dev (depuis racine) 54 | cd .. 55 | npm run dev 56 | ``` 57 | 58 | App dispo sur `http://localhost:3000` 59 | 60 | ### 🔑 Variables d'environnement 61 | 62 | Créer `.env` **à la racine** et dans `backend/` : 63 | 64 | ```bash 65 | # Base de données PostgreSQL 66 | DATABASE_URL="postgresql://user:password@localhost:5432/dbname" 67 | 68 | # Redis (sessions utilisateur) 69 | REDIS_URL="redis://localhost:6379" 70 | 71 | # Secret session (générer string aléatoire sécurisé) 72 | SESSION_SECRET="your-super-secret-key-change-me" 73 | 74 | # Environnement 75 | NODE_ENV="development" 76 | PORT="3000" 77 | 78 | # Stripe (paiements) 79 | STRIPE_SECRET_KEY="sk_test_..." # Clé secrète Stripe 80 | STRIPE_WEBHOOK_SECRET="whsec_..." # Secret webhook Stripe 81 | 82 | # AWS S3 (upload fichiers) 83 | AWS_ACCESS_KEY="AKIA..." 84 | AWS_SECRET="your-secret" 85 | AWS_REGION="eu-west-3" 86 | AWS_BUCKET_NAME="your-bucket" 87 | 88 | # Optionnel : seed BD avec X offres fictives 89 | SEED_OFFERS_COUNT="1000" 90 | ``` 91 | 92 | > ⚠️ **Important** : Copier `.env` dans `backend/` après chaque modif : `cp .env backend/.env` 93 | 94 | ### 📹 Tutoriels vidéo 95 | 96 | Retrouvez le guide vidéo pour configurer ce projet sur YouTube (en français) 97 | 98 | - [Partie 1 : Configurer Remix, NestJS et Turborepo](https://www.youtube.com/watch?v=yv96ar6XNnU&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=1) 99 | - [Partie 2 : CI/CD, Déploiement avec Github Actions et Docker](https://www.youtube.com/watch?v=KCMFcHTYf9o&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=2) 100 | - [Partie 3: Intégration Design System | Figma, Tailwind CSS & Shadcn UI](https://www.youtube.com/watch?v=GWfZewdFx4o&list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM&index=3) 101 | - [Partie 4: Authentification avec Redis, express-session, Passport.js](https://youtu.be/SyuXRIbECEY?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM) 102 | - [Partie 5: Authentification par token, inscription avec Redis, express-session, Passport.js](https://youtu.be/k6KrmuVgvec) 103 | - [Partie 6: Développement des fonctionnalités principales d'échange de service, faire une offre, éditer le profil ...](https://youtu.be/0C4Xh1x7flY) 104 | - [Partie 7: Intégrer Amazon S3 pour héberger les fichiers des utilisateurs](https://youtu.be/4_Q8dsj-X9k) 105 | - [Partie 8: J'ai redesigné ce SaaS avec l'IA | Formation Remix, NestJS, Shadcn UI 2024](https://www.youtube.com/watch?v=ZxYvbvF1dDA) 106 | - [Partie 9: Implémentation de Nuqs: filtres et pagination côté serveur | Formation Remix, NestJS, Shadcn UI 2024](https://youtu.be/4nF_dgbkorw?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM) 107 | - [Partie 10: Implémenter Stripe Connect en multi-tenant | Formation Remix, NestJS, Shadcn UI 2024](https://youtu.be/uRzK8kVGJeY?list=PL2TfCPpDwZVTQr3Ox9KT0Ex2D-QajUyhM) 108 | 109 | ### Motivation 110 | 111 | En 4 ans de développement, je n'ai pas encore trouvé une stack qui me plaît. Il y a toujours un élément qui manque (une fonctionnalité, ou une limitation technique). 112 | 113 | En tant que développeur fullstack, je souhaite bénéficier du meilleur des deux mondes. 114 | 115 | Je souhaite utiliser une technologie : 116 | 117 | - simple à utiliser 118 | - qui me permet d'implémenter une fonctionnalité rapidement 119 | - qui me permet d'avoir un contrôle total sur la logique, front comme back 120 | 121 | [Remix](https://remix.run) répond à mes attentes. C'est un framework frontend qui me permet d'utiliser Javascript et React pour créer des sites web performants et ergonomiques. 122 | 123 | Ce framework est full-stack, signifiant que tu n'as pas besoin de configurer un serveur pour ajouter une logique backend. Tu peux appeler une base de donnée, intégrer l'authentification, et plein d'autres fonctionnalités. 124 | 125 | Cependant, il n'a pas suffisamment de maturité. Il manque plein de features, comme les middleware (qui sont très utiles pour ne pas recopier la même logique de protection des routes) 126 | 127 | J'utilisais donc [NestJS](https://nestjs.com/) comme serveur séparé jusqu'à présent. Ce framework Node.JS m'a permi d'utiliser Javascript pour configurer une base de donnée, des routes et toute ma logique métier. 128 | 129 | Ensuite, j'appelle chaque route dans Remix. Mais c'est sujet à beaucoup d'erreurs d'inattention, ou de perte de synchronisation. J'informe Remix des réponses API de NestJS en déclarant un schéma Zod, qui peut être erroné, et générer des erreurs. 130 | 131 | Je perd donc pas mal de temps à : 132 | 133 | - déclarer des schémas Zod 134 | - réparer des bugs, erreurs d'inattention 135 | - déclarer des méthodes pour appeler mes routes 136 | 137 | MAIS c'est terminé ! J'ai découvert une stack qui me permet d'intégrer ce serveur NestJS avec Remix. Cela remplace le serveur de Remix (celui qui faisait les appels à NestJS) par le serveur NestJS, directement. 138 | 139 | Voici les avantages : 140 | 141 | - aucune duplication de code 142 | - aucun schéma zod 143 | - aucun bug de ce style à régler 144 | 145 | C'est un gain de temps énorme. 146 | 147 | Et dans cette formation, je te montre comment j'ai configuré cette stack pour que tu puisses l'utiliser dans tes projets. 148 | 149 | --- 150 | 151 | ## ⚠️ Note importante 152 | 153 | **Cette stack n'est plus recommandée pour de nouveaux projets.** 154 | 155 | L'écosystème a évolué : Remix a fusionné avec React Router 7, qui simplifie énormément l'architecture. Plus besoin de NestJS, Redis, ni Docker pour du dev local. Ma nouvelle stack (enseignée dans [la formation React Router 7](https://algomax.fr/formation-react-router-7)) est **plus simple, plus rapide, plus maintenable**. 156 | 157 | Ce repo reste utile pour apprendre, mais pour du prod, regarde React Router 7. 158 | 159 | 💬 **Des questions ?** Rejoins le [Discord Algomax](https://algomax.fr/discord) 160 | 161 | --- 162 | 163 | ## 🚀 Next Steps (améliorations possibles) 164 | 165 | Si tu veux moderniser ce projet : 166 | 167 | ### 1. **Retirer Redis** (optionnel) 168 | 169 | Remplacer express-session + Redis par auth en DB avec Prisma. Stocker sessions directement en PostgreSQL. Ça supprime la dépendance Docker/Redis en local. 170 | 171 | ### 2. **Migrer vers React Router 7** 172 | 173 | - Activer tous les Remix future flags 174 | - Updater vers dernière version Remix 175 | - Migrer vers React Router 7 (guide officiel : [reactrouter.com](https://reactrouter.com)) 176 | 177 | ### 3. **Retirer NestJS** (optionnel) 178 | 179 | React Router 7 suffit pour la plupart des apps. Garde NestJS seulement si tu as besoin de microservices, websockets complexes, ou logique backend lourde. 180 | 181 | **Résultat** : Stack ultra-simple avec juste React Router 7 + Prisma + PostgreSQL. 182 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | dist 6 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | // plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | // 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js', 'dist/', 'node_modules/', '**.d.ts'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virgile/backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "main": "./dist/remix/remix.service.js", 9 | "types": "./dist/remix/remix.service.d.ts", 10 | "scripts": { 11 | "dev": "run-p dev:compile dev:watch", 12 | "dev:compile": "tsc --build --watch", 13 | "dev:watch": "nodemon node dist/main.js", 14 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 15 | "prebuild": "rimraf dist tsconfig.tsbuildinfo", 16 | "build": "tsc --build", 17 | "prisma:prestart": "prisma generate", 18 | "prisma:generate": "prisma generate", 19 | "start": "node dist/main", 20 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", 21 | "typecheck": "tsc --noEmit", 22 | "seed:offers": "npm run prisma:generate && ts-node --transpile-only src/scripts/seed-offers.ts" 23 | }, 24 | "dependencies": { 25 | "@aws-sdk/client-s3": "^3.835.0", 26 | "@aws-sdk/s3-request-presigner": "^3.835.0", 27 | "@nestjs/common": "^10.0.0", 28 | "@nestjs/core": "^10.0.0", 29 | "@nestjs/passport": "^10.0.3", 30 | "@nestjs/platform-express": "^10.0.0", 31 | "@paralleldrive/cuid2": "^2.2.2", 32 | "@prisma/client": "^5.12.1", 33 | "@remix-run/express": "^2.8.1", 34 | "bcryptjs": "^2.4.3", 35 | "body-parser": "^1.20.2", 36 | "connect-redis": "^7.1.1", 37 | "express-session": "^1.18.0", 38 | "ioredis": "^5.3.2", 39 | "passport": "^0.7.0", 40 | "passport-local": "^1.0.0", 41 | "reflect-metadata": "^0.2.0", 42 | "rxjs": "^7.8.1", 43 | "stripe": "^18.5.0" 44 | }, 45 | "devDependencies": { 46 | "@nestjs/cli": "^10.0.0", 47 | "@nestjs/schematics": "^10.0.0", 48 | "@nestjs/testing": "^10.0.0", 49 | "@types/bcryptjs": "^2.4.6", 50 | "@types/body-parser": "^1.19.5", 51 | "@types/express": "^4.17.17", 52 | "@types/express-session": "^1.18.0", 53 | "@types/jest": "^29.5.2", 54 | "@types/node": "^20.3.1", 55 | "@types/passport-local": "^1.0.38", 56 | "@virgile/eslint-config": "*", 57 | "@virgile/frontend": "*", 58 | "@virgile/typescript-config": "*", 59 | "nodemon": "^3.1.0", 60 | "npm-run-all": "^4.1.5", 61 | "prettier": "^3.0.0", 62 | "prisma": "^5.12.1", 63 | "source-map-support": "^0.5.21", 64 | "ts-loader": "^9.4.3", 65 | "ts-node": "^10.9.1", 66 | "tsconfig-paths": "^4.2.0", 67 | "typescript": "^5.1.3" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": "src", 76 | "testRegex": ".*\\.spec\\.ts$", 77 | "transform": { 78 | "^.+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "collectCoverageFrom": [ 81 | "**/*.(t|j)s" 82 | ], 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240414130439_init_prisma/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "name" TEXT, 6 | "password" TEXT NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Session" ( 15 | "id" TEXT NOT NULL, 16 | "userId" TEXT NOT NULL, 17 | "ipAddress" TEXT, 18 | "userAgent" TEXT, 19 | "sessionToken" TEXT NOT NULL, 20 | 21 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 22 | ); 23 | 24 | -- CreateIndex 25 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 26 | 27 | -- CreateIndex 28 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 32 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240512104520_add_offers_and_transactions/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Offer" ( 3 | "id" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | "description" TEXT NOT NULL, 8 | "price" DOUBLE PRECISION NOT NULL, 9 | "active" BOOLEAN NOT NULL DEFAULT false, 10 | "recurring" BOOLEAN NOT NULL DEFAULT false, 11 | "userId" TEXT NOT NULL, 12 | 13 | CONSTRAINT "Offer_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Transaction" ( 18 | "id" TEXT NOT NULL, 19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "updatedAt" TIMESTAMP(3) NOT NULL, 21 | "offerId" TEXT NOT NULL, 22 | "userId" TEXT NOT NULL, 23 | 24 | CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") 25 | ); 26 | 27 | -- AddForeignKey 28 | ALTER TABLE "Offer" ADD CONSTRAINT "Offer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 29 | 30 | -- AddForeignKey 31 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "Offer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 32 | 33 | -- AddForeignKey 34 | ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 35 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240514173402_unique_user_offer/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[offerId,userId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Transaction_offerId_userId_key" ON "Transaction"("offerId", "userId"); 9 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240528171404_messages/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Message" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "content" TEXT NOT NULL, 7 | "userId" TEXT NOT NULL, 8 | "transactionId" TEXT NOT NULL, 9 | 10 | CONSTRAINT "Message_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "Message" ADD CONSTRAINT "Message_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 18 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240601114100_add_offer_in_message/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Message" ADD COLUMN "price" DOUBLE PRECISION, 3 | ADD COLUMN "status" INTEGER DEFAULT 0; 4 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250624071110_add_images/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[imageFileKey]` on the table `Offer` will be added. If there are existing duplicate values, this will fail. 5 | - A unique constraint covering the columns `[avatarFileKey]` on the table `User` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "Offer" ADD COLUMN "imageFileKey" TEXT; 10 | 11 | -- AlterTable 12 | ALTER TABLE "User" ADD COLUMN "avatarFileKey" TEXT; 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX "Offer_imageFileKey_key" ON "Offer"("imageFileKey"); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "User_avatarFileKey_key" ON "User"("avatarFileKey"); 19 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250916115632_add_stripe_connect/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[stripeProductId]` on the table `Offer` will be added. If there are existing duplicate values, this will fail. 5 | - A unique constraint covering the columns `[stripePriceId]` on the table `Offer` will be added. If there are existing duplicate values, this will fail. 6 | - A unique constraint covering the columns `[stripePaymentIntentId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail. 7 | - A unique constraint covering the columns `[stripeCheckoutSessionId]` on the table `Transaction` will be added. If there are existing duplicate values, this will fail. 8 | - A unique constraint covering the columns `[stripeAccountId]` on the table `User` will be added. If there are existing duplicate values, this will fail. 9 | - A unique constraint covering the columns `[stripeCustomerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. 10 | 11 | */ 12 | -- AlterTable 13 | ALTER TABLE "Offer" ADD COLUMN "stripePriceId" TEXT, 14 | ADD COLUMN "stripeProductId" TEXT; 15 | 16 | -- AlterTable 17 | ALTER TABLE "Transaction" ADD COLUMN "stripeCheckoutSessionId" TEXT, 18 | ADD COLUMN "stripePaymentIntentId" TEXT; 19 | 20 | -- AlterTable 21 | ALTER TABLE "User" ADD COLUMN "chargesEnabled" BOOLEAN NOT NULL DEFAULT false, 22 | ADD COLUMN "detailsSubmitted" BOOLEAN NOT NULL DEFAULT false, 23 | ADD COLUMN "payoutsEnabled" BOOLEAN NOT NULL DEFAULT false, 24 | ADD COLUMN "stripeAccountId" TEXT, 25 | ADD COLUMN "stripeCustomerId" TEXT; 26 | 27 | -- CreateIndex 28 | CREATE UNIQUE INDEX "Offer_stripeProductId_key" ON "Offer"("stripeProductId"); 29 | 30 | -- CreateIndex 31 | CREATE UNIQUE INDEX "Offer_stripePriceId_key" ON "Offer"("stripePriceId"); 32 | 33 | -- CreateIndex 34 | CREATE UNIQUE INDEX "Transaction_stripePaymentIntentId_key" ON "Transaction"("stripePaymentIntentId"); 35 | 36 | -- CreateIndex 37 | CREATE UNIQUE INDEX "Transaction_stripeCheckoutSessionId_key" ON "Transaction"("stripeCheckoutSessionId"); 38 | 39 | -- CreateIndex 40 | CREATE UNIQUE INDEX "User_stripeAccountId_key" ON "User"("stripeAccountId"); 41 | 42 | -- CreateIndex 43 | CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId"); 44 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | binaryTargets = ["native", "debian-openssl-1.1.x"] 10 | } 11 | 12 | datasource db { 13 | provider = "postgresql" 14 | url = env("DATABASE_URL") 15 | } 16 | 17 | model User { 18 | id String @id @default(cuid()) 19 | email String @unique 20 | name String? 21 | password String 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | avatarFileKey String? @unique 25 | 26 | // Stripe Connect 27 | stripeAccountId String? @unique 28 | stripeCustomerId String? @unique 29 | chargesEnabled Boolean @default(false) 30 | payoutsEnabled Boolean @default(false) 31 | detailsSubmitted Boolean @default(false) 32 | 33 | sessions Session[] 34 | offers Offer[] 35 | transactions Transaction[] 36 | Message Message[] 37 | } 38 | 39 | model Session { 40 | id String @id @default(cuid()) 41 | userId String 42 | user User @relation(fields: [userId], references: [id]) 43 | ipAddress String? 44 | userAgent String? 45 | sessionToken String @unique 46 | } 47 | 48 | model Offer { 49 | id String @id @default(cuid()) 50 | title String 51 | createdAt DateTime @default(now()) 52 | updatedAt DateTime @updatedAt 53 | description String 54 | price Float 55 | active Boolean @default(false) // Est-ce que l'offre est visible sur le site ? 56 | recurring Boolean @default(false) // Exemple : Si l'utilisateur vend un objet, 57 | // il ne peut vendre cet objet qu'une seule fois. 58 | // Par conre, s'il rend un service, il peut le rendre plusieurs fois. Dans ce cas, l'application ne va pas supprimer l'offre après la première vente. 59 | userId String 60 | user User @relation(fields: [userId], references: [id]) 61 | imageFileKey String? @unique 62 | 63 | // Stripe Product/Price mapping (platform-side) 64 | stripeProductId String? @unique 65 | stripePriceId String? @unique 66 | 67 | transactions Transaction[] 68 | } 69 | 70 | model Transaction { 71 | id String @id @default(cuid()) 72 | createdAt DateTime @default(now()) 73 | updatedAt DateTime @updatedAt 74 | offerId String 75 | offer Offer @relation(fields: [offerId], references: [id]) 76 | 77 | userId String 78 | user User @relation(fields: [userId], references: [id]) 79 | messages Message[] 80 | 81 | stripePaymentIntentId String? @unique 82 | stripeCheckoutSessionId String? @unique 83 | 84 | @@unique([offerId, userId]) 85 | } 86 | 87 | model Message { 88 | id String @id @default(cuid()) 89 | createdAt DateTime @default(now()) 90 | updatedAt DateTime @updatedAt 91 | content String 92 | userId String 93 | user User @relation(fields: [userId], references: [id]) 94 | transactionId String 95 | transaction Transaction @relation(fields: [transactionId], references: [id]) 96 | price Float? 97 | status Int? @default(0) // 0 = message, 10 = offre en attente, 20 = offre acceptée, 90 = offre refusée 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from './auth/auth.controller'; 3 | import { AuthModule } from './auth/auth.module'; 4 | import { AwsS3Service } from './aws/aws-s3.service'; 5 | import { PrismaService } from './prisma/prisma.service'; 6 | import { RemixController } from './remix/remix.controller'; 7 | import { RemixService } from './remix/remix.service'; 8 | import { StripeController } from './stripe/stripe.controller'; 9 | import { StripeService } from './stripe/stripe.service'; 10 | 11 | @Module({ 12 | imports: [AuthModule], 13 | controllers: [AuthController, StripeController, RemixController], 14 | providers: [PrismaService, RemixService, AwsS3Service, StripeService], 15 | }) 16 | export class AppModule { } 17 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Next, Post, Query, Redirect, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { NextFunction, Response } from 'express'; 3 | import { LocalAuthGuard } from './local-auth.guard'; 4 | 5 | @Controller() 6 | export class AuthController { 7 | @UseGuards(LocalAuthGuard) 8 | @Get('/authenticate') 9 | @Redirect('/') 10 | login( 11 | @Query('redirectTo') redirectTo: string, 12 | ) { 13 | return { 14 | url: redirectTo 15 | } 16 | } 17 | 18 | @Post('auth/logout') 19 | async logout( 20 | @Req() request: Express.Request, 21 | @Res() response: Response, 22 | @Next() next: NextFunction, 23 | ) { 24 | // this will ensure that re-using the old session id 25 | // does not have a logged in user 26 | request.logOut(function (err) { 27 | if (err) { 28 | return next(err); 29 | } 30 | // Ensure the session is destroyed and the user is redirected. 31 | request.session.destroy(() => { 32 | response.clearCookie('connect.sid'); // The name of the cookie where express/connect stores its session_id 33 | response.redirect('/'); // Redirect to website after logout 34 | }); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { PrismaService } from '../prisma/prisma.service'; 4 | import { AuthService } from './auth.service'; 5 | import { CookieSerializer } from './cookie-serializer'; 6 | import { LocalAuthGuard } from './local-auth.guard'; 7 | import { LocalStrategy } from './local.strategy'; 8 | 9 | @Module({ 10 | imports: [ 11 | PassportModule.register({ 12 | defaultStrategy: 'local', 13 | property: 'user', 14 | session: true 15 | }) 16 | ], 17 | controllers: [], 18 | providers: [LocalStrategy, LocalAuthGuard, CookieSerializer, PrismaService, AuthService], 19 | exports: [AuthService] 20 | }) 21 | export class AuthModule { } 22 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { createId } from '@paralleldrive/cuid2'; 3 | import { compare, hash } from 'bcryptjs'; 4 | import { PrismaService } from '../prisma/prisma.service'; 5 | const PASSWORD_SALT = 10; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | constructor(private readonly prisma: PrismaService) { } 10 | 11 | public readonly checkIfUserExists = async ({ 12 | email, 13 | password, 14 | withPassword, 15 | }: { 16 | email: string; 17 | withPassword: boolean; 18 | password: string; 19 | }) => { 20 | // Renvoie true si l'utilisateur n'existe pas 21 | // Renvoie false si l'utilisateur existe 22 | // 1. Vérifier que l'utilisateur existe sur l'email 23 | // 2. Si withPassword est activé, on vérifie que son mot de passe 24 | // est bien défini. 25 | const existingUser = await this.prisma.user.findUnique({ 26 | where: { 27 | email, 28 | }, 29 | select: { 30 | id: true, 31 | name: true, 32 | email: true, 33 | password: true, 34 | }, 35 | }); 36 | if (!existingUser) { 37 | return { 38 | message: "L'email est invalide", 39 | error: true, 40 | }; 41 | } 42 | 43 | if (withPassword) { 44 | // Rajouter une logique de validation par mot de passez 45 | const isPasswordValid = await compare(password, existingUser.password); 46 | 47 | if (!isPasswordValid) { 48 | return { 49 | message: "Le mot de passe est invalide", 50 | error: true, 51 | }; 52 | } 53 | } 54 | return { 55 | message: "Cet email est déjà utilisé.", 56 | error: false, 57 | }; 58 | }; 59 | 60 | public readonly createUser = async ({ 61 | email, 62 | name, 63 | password, 64 | }: { 65 | email: string; 66 | name: string; 67 | password: string; 68 | }) => { 69 | const hashedPassword = await hash(password, PASSWORD_SALT); 70 | return await this.prisma.user.create({ 71 | data: { 72 | email: email.toLowerCase(), 73 | password: hashedPassword, 74 | name, 75 | }, 76 | select: { 77 | id: true, 78 | name: true, 79 | email: true, 80 | }, 81 | }); 82 | }; 83 | 84 | public readonly authenticateUser = async ({ 85 | email, 86 | }: { 87 | email: string; 88 | }) => { 89 | return await this.prisma.session.create({ 90 | data: { 91 | user: { 92 | connect: { 93 | email 94 | }, 95 | }, 96 | sessionToken: createId() 97 | }, 98 | select: { 99 | sessionToken: true 100 | } 101 | }) 102 | }; 103 | } 104 | -------------------------------------------------------------------------------- /backend/src/auth/cookie-serializer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { Injectable } from '@nestjs/common'; 3 | import { PassportSerializer } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class CookieSerializer extends PassportSerializer { 7 | deserializeUser(payload: any, done: Function) { 8 | // console.log('deserializeUser', { payload }); 9 | done(null, payload); 10 | } 11 | serializeUser(user: any, done: Function) { 12 | // console.log('serializeUser', { user }); 13 | done(null, user); 14 | } 15 | } -------------------------------------------------------------------------------- /backend/src/auth/exception.filter.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; 3 | import { Request, Response } from 'express'; 4 | import { RedirectException } from './redirected-error.exception'; 5 | 6 | @Catch(HttpException) 7 | export class HttpExceptionFilter implements ExceptionFilter { 8 | catch(exception: HttpException, host: ArgumentsHost) { 9 | const ctx = host.switchToHttp(); 10 | const response = ctx.getResponse(); 11 | const request = ctx.getRequest(); 12 | const status = exception.getStatus(); 13 | 14 | if (exception instanceof RedirectException) { 15 | const { redirectUrl, message } = exception 16 | return response.redirect(302, `${redirectUrl}?error=${message}`) 17 | } 18 | 19 | response 20 | .status(status) 21 | .json({ 22 | statusCode: status, 23 | timestamp: new Date().toISOString(), 24 | path: request.url, 25 | }); 26 | } 27 | } -------------------------------------------------------------------------------- /backend/src/auth/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { AuthGuard } from '@nestjs/passport'; 8 | import { Request } from 'express'; 9 | 10 | @Injectable() 11 | export class LocalAuthGuard extends AuthGuard('local') { 12 | async canActivate(context: ExecutionContext): Promise { 13 | const request = context.switchToHttp().getRequest(); 14 | request.body = {}; 15 | request.body.email = 'email@example.com'; 16 | request.body.password = '123'; 17 | 18 | // Add your custom authentication logic here 19 | const canBeActivated = await super.canActivate(context) as boolean; 20 | // for example, call super.logIn(request) to establish a session. 21 | await super.logIn(request); 22 | return canBeActivated 23 | } 24 | 25 | // @ts-expect-error Fix that later 26 | handleRequest(err, user,) { 27 | // console.log({ user, err, info }) 28 | // You can throw an exception based on either "info" or "err" arguments 29 | if (err || !user) { 30 | throw err || new UnauthorizedException("Vous n'avez pas le droit d'accéder à cette page."); 31 | } 32 | return user; 33 | } 34 | } -------------------------------------------------------------------------------- /backend/src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Request } from 'express'; 4 | import { IStrategyOptionsWithRequest, Strategy } from 'passport-local'; 5 | import { PrismaService } from '../prisma/prisma.service'; 6 | import { RedirectException } from './redirected-error.exception'; 7 | 8 | @Injectable() 9 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') { 10 | constructor( 11 | private readonly prisma: PrismaService, 12 | ) { 13 | super({ 14 | passReqToCallback: true, 15 | usernameField: 'email', 16 | } as IStrategyOptionsWithRequest); 17 | } 18 | 19 | async validate(request: Request) { 20 | const token = request.query.token as string 21 | 22 | const session = await this.prisma.session.findUnique({ 23 | where: { 24 | sessionToken: token, 25 | }, 26 | select: { 27 | user: { 28 | select: { 29 | email: true, 30 | id: true, 31 | } 32 | } 33 | }, 34 | }); 35 | if (!session) { 36 | throw new RedirectException("Votre session a expiré. Veuillez vous reconnecter.", '/login') 37 | } 38 | 39 | const { user } = session 40 | 41 | await this.prisma.session.delete({ 42 | where: { 43 | sessionToken: token 44 | } 45 | }) 46 | 47 | return { email: user.email, id: user.id }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/auth/redirected-error.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class RedirectException extends HttpException { 4 | constructor( 5 | public readonly message: string, 6 | public readonly redirectUrl: string, 7 | ) { 8 | super(message, HttpStatus.PERMANENT_REDIRECT); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/aws/aws-s3.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | GetObjectCommand, 4 | PutObjectCommand, 5 | S3Client, 6 | } from '@aws-sdk/client-s3'; 7 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 8 | import { createId } from '@paralleldrive/cuid2'; 9 | import 'dotenv/config'; 10 | import { z } from 'zod'; 11 | 12 | 13 | export const fileSchema = z.object({ 14 | size: z.number(), 15 | buffer: z.instanceof(Buffer), 16 | originalname: z.string(), 17 | mimetype: z.string(), 18 | }); 19 | 20 | const AWS_ACCESS_KEY = process.env.AWS_ACCESS_KEY; 21 | const AWS_SECRET = process.env.AWS_SECRET; 22 | const AWS_REGION = process.env.AWS_REGION; 23 | 24 | export class AwsS3Service { 25 | private readonly client: S3Client; 26 | constructor() { 27 | if (!AWS_ACCESS_KEY) { 28 | throw new Error('Invalid AWS_ACCESS_KEY'); 29 | } 30 | 31 | if (!AWS_SECRET) { 32 | throw new Error('Invalid AWS_SECRET'); 33 | } 34 | if (!AWS_REGION) { 35 | throw new Error('Invalid AWS_REGION'); 36 | } 37 | const client = new S3Client({ 38 | credentials: { 39 | accessKeyId: AWS_ACCESS_KEY, 40 | secretAccessKey: AWS_SECRET, 41 | }, 42 | region: AWS_REGION, 43 | }); 44 | this.client = client; 45 | } 46 | 47 | async uploadFile({ file }: { file: z.infer }) { 48 | const createdId = createId(); 49 | const fileKey = createdId + file.originalname; 50 | const putObjectCommand = new PutObjectCommand({ 51 | Bucket: process.env.AWS_BUCKET_NAME, 52 | Key: fileKey, 53 | ContentType: file.mimetype, 54 | Body: file.buffer, 55 | CacheControl: 'max-age=31536000', 56 | }); 57 | 58 | const result = await this.client.send(putObjectCommand); 59 | if (result.$metadata.httpStatusCode !== 200) { 60 | console.error(result); 61 | } 62 | return { fileKey }; 63 | } 64 | 65 | async deleteFile({ fileKey }: { fileKey: string }) { 66 | const deleteObjectCommand = new DeleteObjectCommand({ 67 | Bucket: process.env.AWS_BUCKET_NAME, 68 | Key: fileKey, 69 | }); 70 | 71 | const result = await this.client.send(deleteObjectCommand); 72 | if (result.$metadata.httpStatusCode !== 200) { 73 | // console.error(result); 74 | } 75 | } 76 | 77 | async getFileUrl({ fileKey }: { fileKey: string }) { 78 | const getObjectCommand = new GetObjectCommand({ 79 | Bucket: process.env.AWS_BUCKET_NAME, 80 | Key: fileKey, 81 | }); 82 | 83 | const result = await getSignedUrl(this.client, getObjectCommand); 84 | return result; 85 | } 86 | } -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { getPublicDir, startDevServer } from '@virgile/frontend'; 4 | import { AppModule } from './app.module'; 5 | 6 | import { raw, urlencoded } from 'body-parser'; 7 | import RedisStore from 'connect-redis'; 8 | import session from 'express-session'; 9 | import Redis from 'ioredis'; 10 | import passport from 'passport'; 11 | import { HttpExceptionFilter } from './auth/exception.filter'; 12 | 13 | async function bootstrap() { 14 | const app = await NestFactory.create(AppModule, { 15 | bodyParser: false, 16 | }); 17 | 18 | await startDevServer(app); 19 | app.use('/webhooks/stripe', raw({ type: 'application/json' })); 20 | 21 | // Initialize client 22 | const redisUrl = process.env.REDIS_URL || "redis://localhost:6379" 23 | const redisClient = new Redis(redisUrl, { 24 | 25 | }).on('error', console.error).on('connect', () => { 26 | console.log('Connected to Redis'); 27 | }); 28 | 29 | // Initialize store 30 | const redisStore = new RedisStore({ 31 | client: redisClient, 32 | ttl: 86400 * 30 33 | }) 34 | 35 | app.set('trust proxy', 1) 36 | 37 | app.use( 38 | session({ 39 | store: redisStore, 40 | resave: false, // required: force lightweight session keep alive (touch) 41 | saveUninitialized: false, // recommended: only save session when data exists 42 | secret: process.env.SESSION_SECRET || '123', 43 | cookie: { 44 | maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days 45 | sameSite: "lax", 46 | secure: process.env.NODE_ENV === 'production', 47 | } 48 | }), 49 | ) 50 | 51 | app.useStaticAssets(getPublicDir(), { 52 | immutable: true, 53 | maxAge: '1y', 54 | index: false, 55 | }); 56 | 57 | app.useGlobalFilters(new HttpExceptionFilter()); 58 | 59 | app.use(passport.initialize()) 60 | app.use(passport.session()) 61 | app.use("/authenticate", urlencoded({ extended: true })) // Add this line 62 | app.use("/auth/logout", urlencoded({ extended: true })) // Add this line 63 | 64 | const selectedPort = process.env.PORT ?? 3000; 65 | 66 | console.log(`Running on port http://localhost:${selectedPort}`); 67 | await app.listen(selectedPort); 68 | } 69 | bootstrap(); 70 | -------------------------------------------------------------------------------- /backend/src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient { } -------------------------------------------------------------------------------- /backend/src/remix/remix.controller.ts: -------------------------------------------------------------------------------- 1 | import { All, Controller, Next, Req, Res } from '@nestjs/common'; 2 | import { createRequestHandler } from '@remix-run/express'; 3 | import { getServerBuild } from '@virgile/frontend'; 4 | import { NextFunction, Request, Response } from 'express'; 5 | import { RemixService } from './remix.service'; 6 | 7 | @Controller() 8 | export class RemixController { 9 | constructor(private remixService: RemixService) { } 10 | 11 | @All('*') 12 | async handler( 13 | @Req() request: Request, 14 | @Res() response: Response, 15 | @Next() next: NextFunction, 16 | ) { 17 | // 18 | return createRequestHandler({ 19 | build: await getServerBuild(), 20 | getLoadContext: () => ({ 21 | user: request.user, 22 | remixService: this.remixService, 23 | }), 24 | })(request, response, next); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/remix/remix.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthService } from '../auth/auth.service'; 3 | import { AwsS3Service } from '../aws/aws-s3.service'; 4 | import { PrismaService } from '../prisma/prisma.service'; 5 | import { StripeService } from '../stripe/stripe.service'; 6 | 7 | // http://localhost:3000/webhooks/stripe 8 | @Injectable() 9 | export class RemixService { 10 | constructor( 11 | public readonly prisma: PrismaService, 12 | public readonly auth: AuthService, 13 | public readonly aws: AwsS3Service, 14 | public readonly stripe: StripeService, 15 | ) { } 16 | public readonly getUser = async ({ userId }: { userId: string }) => { 17 | return await this.prisma.user.findUnique({ 18 | where: { 19 | id: userId, 20 | }, 21 | select: { 22 | id: true, 23 | name: true, 24 | email: true, 25 | }, 26 | }); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/scripts/seed-offers.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | type SeedUser = { 7 | name: string; 8 | email: string; 9 | passwordHash: string; 10 | }; 11 | 12 | const PRESET_USERS: Array<{ name: string; email: string }> = [ 13 | { name: "Jean Dupont", email: "jean.dupont+seed@coupdepouce.test" }, 14 | { name: "Marie Curie", email: "marie.curie+seed@coupdepouce.test" }, 15 | { name: "Paul Martin", email: "paul.martin+seed@coupdepouce.test" }, 16 | { name: "Sophie Bernard", email: "sophie.bernard+seed@coupdepouce.test" }, 17 | { name: "Luc Moreau", email: "luc.moreau+seed@coupdepouce.test" }, 18 | { name: "Emma Lefevre", email: "emma.lefevre+seed@coupdepouce.test" }, 19 | { name: "Hugo Garcia", email: "hugo.garcia+seed@coupdepouce.test" }, 20 | { name: "Chloé Lambert", email: "chloe.lambert+seed@coupdepouce.test" }, 21 | { name: "Louis Fontaine", email: "louis.fontaine+seed@coupdepouce.test" }, 22 | { name: "Camille Mercier", email: "camille.mercier+seed@coupdepouce.test" }, 23 | { name: "Nathan Garnier", email: "nathan.garnier+seed@coupdepouce.test" }, 24 | { name: "Léa Faure", email: "lea.faure+seed@coupdepouce.test" }, 25 | { name: "Julien Caron", email: "julien.caron+seed@coupdepouce.test" }, 26 | { name: "Manon Chevalier", email: "manon.chevalier+seed@coupdepouce.test" }, 27 | { name: "Mathis Lucas", email: "mathis.lucas+seed@coupdepouce.test" }, 28 | { name: "Inès Robin", email: "ines.robin+seed@coupdepouce.test" }, 29 | { name: "Tom Robert", email: "tom.robert+seed@coupdepouce.test" }, 30 | { name: "Clara Gaillard", email: "clara.gaillard+seed@coupdepouce.test" }, 31 | { name: "Noah Guerin", email: "noah.guerin+seed@coupdepouce.test" }, 32 | { name: "Sarah Masson", email: "sarah.masson+seed@coupdepouce.test" }, 33 | ]; 34 | 35 | const CATEGORIES = [ 36 | "jardinage", 37 | "bricolage", 38 | "ménage", 39 | "cuisine", 40 | "garde d'enfants", 41 | "cours particuliers", 42 | "aide administrative", 43 | "informatique", 44 | "coaching sportif", 45 | "photographie", 46 | "musique", 47 | "déménagement", 48 | "livraison", 49 | "promenade d'animaux", 50 | "montage de meubles", 51 | "peinture", 52 | "plomberie légère", 53 | "électricité légère", 54 | "traduction", 55 | "rédaction", 56 | ] as const; 57 | 58 | const SERVICE_DESCRIPTIONS = [ 59 | "Service entre particuliers, flexible et au juste prix.", 60 | "Intervention rapide, sérieuse et conviviale.", 61 | "Matériel fourni si besoin, échanges simples et sécurisés.", 62 | "Expérience confirmée, satisfaction garantie.", 63 | "Idéal pour un coup de pouce près de chez vous.", 64 | "Horaires souples, adaptation à vos contraintes.", 65 | "Conseils personnalisés et suivi.", 66 | "Tarif transparent, sans surprise.", 67 | ]; 68 | 69 | const ADJECTIFS = [ 70 | "rapide", 71 | "soigné", 72 | "fiable", 73 | "efficace", 74 | "sympa", 75 | "professionnel", 76 | "minutieux", 77 | "ponctuel", 78 | "attentionné", 79 | "rigoureux", 80 | ]; 81 | 82 | function pick(arr: readonly T[]): T { 83 | return arr[Math.floor(Math.random() * arr.length)]; 84 | } 85 | 86 | function buildTitle(): string { 87 | const cat = pick(CATEGORIES); 88 | const adj = pick(ADJECTIFS); 89 | // Ex: "Aide jardinage - service rapide et soigné" 90 | return `Aide ${cat} — service ${adj}`; 91 | } 92 | 93 | function buildDescription(): string { 94 | const d1 = pick(SERVICE_DESCRIPTIONS); 95 | const d2 = pick(SERVICE_DESCRIPTIONS); 96 | const d3 = pick(SERVICE_DESCRIPTIONS); 97 | return `${d1} ${d2} ${d3}`; 98 | } 99 | 100 | async function upsertUsers(): Promise { 101 | const salt = await bcrypt.genSalt(10); 102 | const passwordHash = await bcrypt.hash("motdepasse123", salt); 103 | 104 | const created = await Promise.all( 105 | PRESET_USERS.map(async (u) => { 106 | const user = await prisma.user.upsert({ 107 | where: { email: u.email }, 108 | update: { name: u.name, password: passwordHash }, 109 | create: { name: u.name, email: u.email, password: passwordHash }, 110 | select: { id: true }, 111 | }); 112 | return user.id; 113 | }) 114 | ); 115 | 116 | return created; 117 | } 118 | 119 | async function main(): Promise { 120 | const count = Number(process.env.SEED_OFFERS_COUNT ?? 1000); 121 | // Ensure we have a stable pool of users to attach offers to 122 | const userIds = await upsertUsers(); 123 | 124 | // Generate 1000 distinct prices from 10.00€ upward, +0.01€ each 125 | // Guarantees uniqueness and realism for a P2P service marketplace 126 | const baseCents = 1000; // 10.00€ 127 | 128 | const offerRows: Array<{ 129 | title: string; 130 | description: string; 131 | price: number; 132 | active: boolean; 133 | recurring: boolean; 134 | userId: string; 135 | }> = []; 136 | 137 | for (let i = 0; i < count; i++) { 138 | const priceCents = baseCents + i; // unique price each time 139 | const price = Math.round(priceCents) / 100; 140 | const userId = userIds[i % userIds.length]; 141 | 142 | offerRows.push({ 143 | title: buildTitle(), 144 | description: buildDescription(), 145 | price, 146 | active: true, 147 | recurring: true, 148 | userId, 149 | }); 150 | } 151 | 152 | // Clean up previous seed (optional): we keep idempotency by not deleting; prices are unique per run 153 | // Using createMany for performance 154 | const result = await prisma.offer.createMany({ data: offerRows }); 155 | 156 | // eslint-disable-next-line no-console 157 | console.log(`✅ Création d'offres terminée: ${result.count} offres créées.`); 158 | } 159 | 160 | main() 161 | .catch((err) => { 162 | // eslint-disable-next-line no-console 163 | console.error("❌ Erreur lors du seed des offres:", err); 164 | process.exitCode = 1; 165 | }) 166 | .finally(async () => { 167 | await prisma.$disconnect(); 168 | }); 169 | 170 | 171 | -------------------------------------------------------------------------------- /backend/src/stripe/stripe.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Headers, Post, Req, Res } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { PrismaService } from '../prisma/prisma.service'; 4 | import { StripeService } from './stripe.service'; 5 | 6 | @Controller() 7 | export class StripeController { 8 | constructor( 9 | private readonly stripeService: StripeService, 10 | private readonly prisma: PrismaService, 11 | ) {} 12 | 13 | @Post('/webhooks/stripe') 14 | async handleWebhook( 15 | @Req() req: Request, 16 | @Res() res: Response, 17 | @Headers('stripe-signature') sig?: string, 18 | @Body() _body?: unknown, // parsed ignored; we use raw 19 | ) { 20 | try { 21 | const event = this.stripeService.constructEventFromPayload(sig, req.body as unknown as Buffer); 22 | 23 | if (event.type === 'account.updated') { 24 | const account = event.data.object ; 25 | const user = await this.prisma.user.findFirst({ 26 | where: { stripeAccountId: account.id }, 27 | select: { id: true }, 28 | }); 29 | if (user) { 30 | await this.prisma.user.update({ 31 | where: { id: user.id }, 32 | data: { 33 | chargesEnabled: Boolean(account.charges_enabled), 34 | payoutsEnabled: Boolean(account.payouts_enabled), 35 | detailsSubmitted: Boolean(account.details_submitted), 36 | }, 37 | }); 38 | } 39 | } 40 | 41 | if (event.type === 'checkout.session.completed' || event.type === 'payment_intent.succeeded') { 42 | await this.stripeService.handleWebhookEvent(event); 43 | } 44 | 45 | res.status(200).send({ received: true }); 46 | } catch (err) { 47 | console.error('Stripe webhook error', err); 48 | res.status(400).send(`Webhook Error`); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /backend/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | cd backend 5 | npx prisma migrate deploy 6 | npm run start -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@virgile/typescript-config/base.json", 3 | "compilerOptions": { 4 | "emitDecoratorMetadata": true, 5 | "experimentalDecorators": true, 6 | "strict": true, 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "noEmit": false, 10 | "lib": ["DOM", "ES2023"] 11 | }, 12 | "include": ["src/**/*.ts", "src/exports.js"] 13 | } 14 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": ["**/*.json"] 8 | }, 9 | 10 | "formatter": { 11 | "enabled": true, 12 | "indentStyle": "space", 13 | "indentWidth": 2 14 | }, 15 | "javascript": { 16 | "parser": { 17 | "unsafeParameterDecoratorsEnabled": true 18 | } 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "correctness": { 25 | "noUnusedImports": "error", 26 | "noUnusedVariables": "error", 27 | "useExhaustiveDependencies": "off", 28 | "noUnsafeOptionalChaining": "off" 29 | }, 30 | "style": { 31 | "noUnusedTemplateLiteral": "off", 32 | "useTemplate": "off", 33 | "useSelfClosingElements": "off", 34 | "noParameterAssign": "off", 35 | "noUselessElse": "off", 36 | "useImportType": "off" 37 | }, 38 | "security": { 39 | "noDangerouslySetInnerHtml": "off" 40 | }, 41 | "a11y": { 42 | "useButtonType": "off", 43 | "noSvgWithoutTitle": "off", 44 | "noAutofocus": "off" 45 | }, 46 | "complexity": { 47 | "noUselessFragments": "off", 48 | "useLiteralKeys": "off", 49 | "noForEach": "off", 50 | "useOptionalChain": "off" 51 | }, 52 | "suspicious": { 53 | "noArrayIndexKey": "off", 54 | "noExplicitAny": "off", 55 | "noMisleadingCharacterClass": "off", 56 | "noAssignInExpressions": "off", 57 | "noShadowRestrictedNames": "off", 58 | "noGlobalIsNan": "off", 59 | "useDefaultSwitchClauseLast": "off" 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cache/dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Varkoff/remix-nestjs-monorepo/de83000dd44bfb70ea687c440765f6964c128444/cache/dump.rdb -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | monorepo_dev: 3 | environment: 4 | - REDIS_URL=redis://redis_dev:6379 5 | - NODE_ENV=development 6 | - DATABASE_URL 7 | 8 | container_name: nestjs-remix-monorepo-dev 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | # image: varkoff/nestjs-remix-monorepo:dev 13 | restart: always 14 | ports: 15 | - 3000:3000 16 | redis_dev: 17 | image: redis:latest 18 | restart: always 19 | ports: 20 | - '6379:6379' 21 | command: ["redis-server"] 22 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 23 | volumes: 24 | - ./cache:/data 25 | # - /dev/volumes/nestjs-remix/dev/sessions/:/data 26 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | monorepo_prod: 3 | environment: 4 | - REDIS_URL=redis://redis_prod:6379 5 | - NODE_ENV 6 | - DATABASE_URL 7 | 8 | 9 | container_name: nestjs-remix-monorepo-prod 10 | # build: 11 | # context: . 12 | # dockerfile: Dockerfile 13 | image: varkoff/nestjs-remix-monorepo:production 14 | restart: always 15 | ports: 16 | - 3000:3000 17 | redis_prod: 18 | image: redis:latest 19 | restart: always 20 | # ports: 21 | # - '6379:6379' 22 | command: ["redis-server"] 23 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 24 | volumes: 25 | - /dev/volumes/nestjs-remix/production/sessions/:/data 26 | 27 | -------------------------------------------------------------------------------- /docker-compose.redis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis_standalone: 3 | image: redis:latest 4 | restart: always 5 | ports: 6 | - '6379:6379' 7 | command: ["redis-server"] 8 | # --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 9 | volumes: 10 | - ./cache:/data 11 | # - /dev/volumes/nestjs-remix/dev/sessions/:/data 12 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | *.log 4 | .DS_Store 5 | .env 6 | /.cache 7 | /public/build 8 | /build -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | '@virgile/eslint-config/base.js', 6 | '@remix-run/eslint-config', 7 | '@remix-run/eslint-config/node', 8 | // 'plugin:tailwindcss/recommended', 9 | 'plugin:remix-react-routes/recommended', 10 | ], 11 | settings: { 12 | // tailwindcss: { 13 | // config: 'tailwind.config.ts', 14 | // }, 15 | 'import/resolver': { 16 | node: { 17 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 18 | }, 19 | }, 20 | }, 21 | rules: { 22 | "import/no-unresolved": "off", 23 | }, 24 | 25 | overrides: [ 26 | { 27 | extends: ['@remix-run/eslint-config/jest-testing-library'], 28 | files: ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*'], 29 | rules: { 30 | 'testing-library/no-await-sync-events': 'off', 31 | 'jest-dom/prefer-in-document': 'off', 32 | }, 33 | // we're using vitest which has a very similar API to jest 34 | // (so the linting plugins work nicely), but it means we have to explicitly 35 | // set the jest version. 36 | settings: { 37 | jest: { 38 | version: 28, 39 | }, 40 | }, 41 | 42 | }, 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | -------------------------------------------------------------------------------- /frontend/app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react'; 2 | 3 | export const Footer = () => { 4 | return ( 5 |
6 |
7 |
8 | © 2024 Coup de Pouce. Tous droits réservés. 9 |
10 | 11 |
12 | 16 | Mentions légales 17 | 18 | 19 | 23 | CGV 24 | 25 |
26 |
27 |
28 | ); 29 | }; -------------------------------------------------------------------------------- /frontend/app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import { useInputControl } from '@conform-to/react'; 2 | import { useId } from 'react'; 3 | import { Checkbox, type CheckboxProps } from './ui/checkbox'; 4 | import { Input } from './ui/input'; 5 | import { Label } from './ui/label'; 6 | import { Textarea } from './ui/textarea'; 7 | 8 | export function Field({ 9 | labelProps, 10 | inputProps, 11 | errors, 12 | className, 13 | }: { 14 | labelProps: React.LabelHTMLAttributes 15 | inputProps: React.InputHTMLAttributes 16 | errors?: string[] | undefined; 17 | className?: string 18 | }) { 19 | const fallbackId = useId() 20 | const id = inputProps.id ?? fallbackId 21 | const errorId = errors?.length ? `${id}-error` : undefined 22 | return ( 23 |
24 |
36 | ) 37 | } 38 | export function TextareaField({ 39 | labelProps, 40 | textareaProps, 41 | errors, 42 | className, 43 | }: { 44 | labelProps: React.LabelHTMLAttributes 45 | textareaProps: React.TextareaHTMLAttributes 46 | errors?: string[] | undefined; 47 | className?: string 48 | }) { 49 | const fallbackId = useId() 50 | const id = textareaProps.id ?? textareaProps.name ?? fallbackId 51 | const errorId = errors?.length ? `${id}-error` : undefined 52 | return ( 53 |
54 |