From cdf245ca2953f6f8cc1c5ee475de95928a0d8b9a Mon Sep 17 00:00:00 2001 From: Varkoff Date: Sun, 7 Jul 2024 19:32:28 +0200 Subject: [PATCH] Mise en prod --- .dockerignore | 11 + .github/workflows/build.yml | 72 +++++ .github/workflows/ci.yml | 74 +++++ Dockerfile | 58 ++++ docker-compose.dev.yaml | 23 ++ package-lock.json | 106 +++++-- package.json | 169 ++++++------ .../20240707171540_init/migration.sql | 107 +++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 68 +++-- src/app.module.ts | 2 + src/auth/auth.module.ts | 2 + src/chat/chat.service.ts | 20 ++ src/stripe/stripe.controller.ts | 47 ++++ src/stripe/stripe.module.ts | 10 + src/stripe/stripe.service.ts | 261 ++++++++++++++++++ src/user/user.module.ts | 3 +- src/user/user.service.ts | 12 +- start.sh | 5 + 19 files changed, 914 insertions(+), 139 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.dev.yaml create mode 100644 prisma/migrations/20240707171540_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/stripe/stripe.controller.ts create mode 100644 src/stripe/stripe.module.ts create mode 100644 src/stripe/stripe.service.ts create mode 100755 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48893d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +dist +node_modules +test +.dockerignore +.env +.env.example +.eslintrc.js +.gitignore +.prettierrc +Dockerfile +README.md \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6ce8bad --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,72 @@ +name: 🐳 Build And Push Docker Image +on: + workflow_call: + inputs: + tag: + type: string + description: The tag to push to the Docker registry. + # required: true + # default: latest + +jobs: + build: + name: 🐳 Build + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4.1.1 + + - name: πŸ§‘β€πŸ’» Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + logout: true + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + + # Setup cache + - name: ⚑️ Cache Docker layers + uses: actions/cache@v4.0.0 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }}-${{ github.ref_name }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: 🐳 Build Production Image + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/build-push-action@v5.1.0 + with: + context: . + push: true + tags: algomax/nestjs-chat-api:production + build-args: | + COMMIT_SHA=${{ github.sha }} \ + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + - name: 🐳 Build Staging Image + if: ${{ github.ref == 'refs/heads/dev' }} + uses: docker/build-push-action@v5.1.0 + with: + context: . + push: true + tags: algomax/nestjs-chat-api:latest + build-args: | + COMMIT_SHA=${{ github.sha }} \ + + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + # This ugly bit is necessary if you don't want your cache to grow forever + # till it hits GitHub's limit of 5GB. + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: 🚚 Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ee8f6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: πŸš€ Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +permissions: + actions: write + contents: read + +jobs: + build: + name: 🐳 build + uses: ./.github/workflows/build.yml + secrets: inherit + + deploy: + name: πŸš€ Deploy + runs-on: [self-hosted] + needs: [build] + # needs: [build] + # only build/deploy main branch on pushes + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + env: + JWT_SECRET: ${{ secrets.JWT_SECRET }} + PORT: ${{ secrets.PORT }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + FRONTEND_URL: ${{ secrets.FRONTEND_URL }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET: ${{ secrets.AWS_SECRET }} + AWS_BUCKET_NAME: ${{ secrets.AWS_BUCKET_NAME }} + AWS_REGION: ${{ secrets.AWS_REGION }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + + steps: + - name: Cache node modules + uses: actions/cache@v4.0.0 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: ⬇️ Checkout repo + uses: actions/checkout@v4.1.1 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # - name: πŸš€ Run Docker Compose on Staging + # if: ${{ github.ref == 'refs/heads/dev' }} + # env: + # DATABASE_URL: ${{ secrets.DATABASE_URL_STAGING }} + # FRONTEND_URL: ${{ secrets.FRONTEND_URL_STAGING }} + # run: | + # docker pull algomax/nestjs-chat-api:latest + # docker compose -f docker-compose.dev.yaml up -d + # docker system prune --all --volumes --force + + - name: πŸš€ Run Docker Compose on Production + if: ${{ github.ref == 'refs/heads/main' }} + # env: + # FRONTEND_URL: ${{ secrets.FRONTEND_URL }} + # DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + docker pull algomax/nestjs-chat-api:production + docker compose -f docker-compose.prod.yaml up -d + docker system prune --all --volumes --force \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..63ccd64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# https://www.tomray.dev/nestjs-docker-production +# BUILD FOR PRODUCTION +FROM node:20-alpine As base + +ENV NODE_ENV="production" + +FROM base AS installer + +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY --chown=node:node ./package*.json ./ +COPY --chown=node:node ./start.sh ./start.sh +COPY --chown=node:node . . + +RUN npm install --include=dev + +ADD prisma prisma + +RUN npx prisma generate + +RUN npm run build + +FROM base as prunner +WORKDIR /app + +COPY --from=installer /app/node_modules ./node_modules +COPY ./package*.json ./ + +RUN npm prune --omit=dev + +FROM base AS runner +WORKDIR /app + +ENV TZ=Europe/Paris + +RUN apk add --no-cache tzdata \ + && cp /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && apk del tzdata + +# Don't run production as root +RUN addgroup --system --gid 1024 nodejs +RUN adduser --system --uid 1024 nestjs + +USER nestjs + +COPY --chown=nestjs:nodejs --from=prunner /app/package.json ./package.json +COPY --chown=nestjs:nodejs --from=installer /app/dist ./dist +COPY --chown=nestjs:nodejs --from=prunner /app/node_modules ./node_modules +COPY --chown=nestjs:nodejs --from=installer /app/start.sh ./start.sh +COPY --chown=nestjs:nodejs --from=installer /app/prisma ./prisma + +# CMD ["sh", "-c", "while :; do echo 'Container is running...'; sleep 60; done"] + +CMD ["sh", "start.sh"] +# ENTRYPOINT ["start.sh"] \ No newline at end of file diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..7535392 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,23 @@ +services: + nestjs-chat-api_dev: + environment: + - DATABASE_URL + - JWT_SECRET + - PORT + - RESEND_API_KEY + - FRONTEND_URL + - AWS_ACCESS_KEY + - AWS_SECRET + - AWS_BUCKET_NAME + - AWS_REGION + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET + + + container_name: nestjs-chat-api_dev + build: + context: . + dockerfile: Dockerfile + restart: always + ports: + - 8000:8000 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cb9e57d..34203c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@nestjs/platform-socket.io": "^10.2.7", "@nestjs/websockets": "^10.2.7", "@paralleldrive/cuid2": "^2.2.2", - "@prisma/client": "^5.5.0", + "@prisma/client": "^5.16.1", "@types/bcrypt": "^5.0.1", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", @@ -31,6 +31,7 @@ "resend": "^1.1.0", "rxjs": "^7.8.1", "socket.io": "^4.7.2", + "stripe": "^14.13.0", "zod": "^3.22.4" }, "devDependencies": { @@ -49,12 +50,12 @@ "eslint-plugin-prettier": "^5.0.1", "jest": "^29.7.0", "prettier": "^3.0.3", - "prisma": "^5.5.0", + "prisma": "^5.16.1", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.1", - "ts-loader": "^9.5.0", - "ts-node": "^10.9.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.2.2" } @@ -2920,13 +2921,11 @@ } }, "node_modules/@prisma/client": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.5.0.tgz", - "integrity": "sha512-JiCj/h79PRawWiBVa4Ng4tBaKMrfJgUwuRrZi4NuQRd58gWIP4kEYMqIidpUrab3dU2NM617pWRG2avX+dh+Sg==", + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.16.1.tgz", + "integrity": "sha512-wM9SKQjF0qLxdnOZIVAIMKiz6Hu7vDt4FFAih85K1dk/Rr2mdahy6d3QP41K62N9O0DJJA//gUDA3Mp49xsKIg==", "hasInstallScript": true, - "dependencies": { - "@prisma/engines-version": "5.5.0-19.475c616176945d72f4330c92801f0c5e6398dc0f" - }, + "license": "Apache-2.0", "engines": { "node": ">=16.13" }, @@ -2939,17 +2938,55 @@ } } }, + "node_modules/@prisma/debug": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.16.1.tgz", + "integrity": "sha512-JsNgZAg6BD9RInLSrg7ZYzo11N7cVvYArq3fHGSD89HSgtN0VDdjV6bib7YddbcO6snzjchTiLfjeTqBjtArVQ==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/engines": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.5.0.tgz", - "integrity": "sha512-WX+8l4sJuWeS3A/5WqJpdBHp32UzUticiKwUWbFggcd6/sqGtCiiaFmXYmNw6Au2O6hjSX37Y07Vu2ZhP9cmWg==", + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.16.1.tgz", + "integrity": "sha512-KkyF3eIUtBIyp5A/rJHCtwQO18OjpGgx18PzjyGcJDY/+vNgaVyuVd+TgwBgeq6NLdd1XMwRCI+58vinHsAdfA==", "devOptional": true, - "hasInstallScript": true + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/fetch-engine": "5.16.1", + "@prisma/get-platform": "5.16.1" + } }, "node_modules/@prisma/engines-version": { - "version": "5.5.0-19.475c616176945d72f4330c92801f0c5e6398dc0f", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.5.0-19.475c616176945d72f4330c92801f0c5e6398dc0f.tgz", - "integrity": "sha512-/1orOUq7I54ISYBJockYB15FmF/VkCBeFzYYkWyLtBl2tLlJWHmA3NEUBM+wSmXGLK78BmuSNHrLbf8bmXkukQ==" + "version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303.tgz", + "integrity": "sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.16.1.tgz", + "integrity": "sha512-oOkjaPU1lhcA/Rvr4GVfd1NLJBwExgNBE36Ueq7dr71kTMwy++a3U3oLd2ZwrV9dj9xoP6LjCcky799D9nEt4w==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1", + "@prisma/engines-version": "5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303", + "@prisma/get-platform": "5.16.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.16.1.tgz", + "integrity": "sha512-R4IKnWnMkR2nUAbU5gjrPehdQYUUd7RENFD2/D+xXTNhcqczp0N+WEGQ3ViyI3+6mtVcjjNIMdnUTNyu3GxIgA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.16.1" + } }, "node_modules/@react-email/render": { "version": "0.0.7", @@ -9422,13 +9459,14 @@ } }, "node_modules/prisma": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.5.0.tgz", - "integrity": "sha512-QyMMh1WQiGU07Iz3Q8jd6y5KlomGLDVb4etkMWpQ4EmDAaY+zGgNHmBk2MfRPb0A+un1Ior1nZWZorfnKD6E5A==", + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.16.1.tgz", + "integrity": "sha512-Z1Uqodk44diztImxALgJJfNl2Uisl9xDRvqybMKEBYJLNKNhDfAHf+ZIJbZyYiBhLMbKU9cYGdDVG5IIXEnL2Q==", "devOptional": true, "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "5.5.0" + "@prisma/engines": "5.16.1" }, "bin": { "prisma": "build/index.js" @@ -10376,6 +10414,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "14.13.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.13.0.tgz", + "integrity": "sha512-uLOfWtBUL1amJCTpKCXWrHntFOSaO2PWb/2hsxV/OlXLr0bz5MyU8IW1pFlmZqpw6hBqAW5Fad7Ty7xRxDYrzA==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -10766,10 +10816,11 @@ } }, "node_modules/ts-loader": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.0.tgz", - "integrity": "sha512-LLlB/pkB4q9mW2yLdFMnK3dEHbrBjeZTYguaaIfusyojBgAGf5kF+O6KcWqiGzWqHk0LBsoolrp4VftEURhybg==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", + "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", @@ -10786,10 +10837,11 @@ } }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", diff --git a/package.json b/package.json index 151360a..132af7b 100644 --- a/package.json +++ b/package.json @@ -1,89 +1,84 @@ { - "name": "nestjs-chat", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.454.0", - "@aws-sdk/s3-request-presigner": "^3.468.0", - "@nestjs/common": "^10.2.7", - "@nestjs/config": "^3.1.1", - "@nestjs/core": "^10.2.7", - "@nestjs/jwt": "^10.1.1", - "@nestjs/passport": "^10.0.2", - "@nestjs/platform-express": "^10.2.7", - "@nestjs/platform-socket.io": "^10.2.7", - "@nestjs/websockets": "^10.2.7", - "@paralleldrive/cuid2": "^2.2.2", - "@prisma/client": "^5.5.0", - "@types/bcrypt": "^5.0.1", - "bcrypt": "^5.1.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", - "dotenv": "^16.3.1", - "passport-jwt": "^4.0.1", - "reflect-metadata": "^0.1.13", - "resend": "^1.1.0", - "rxjs": "^7.8.1", - "socket.io": "^4.7.2", - "zod": "^3.22.4" - }, - "devDependencies": { - "@nestjs/cli": "^10.2.0", - "@nestjs/schematics": "^10.0.2", - "@nestjs/testing": "^10.2.7", - "@types/express": "^4.17.20", - "@types/jest": "^29.5.6", - "@types/multer": "^1.4.11", - "@types/node": "^20.8.8", - "@types/supertest": "^2.0.15", - "@typescript-eslint/eslint-plugin": "^6.9.0", - "@typescript-eslint/parser": "^6.9.0", - "eslint": "^8.52.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.1", - "jest": "^29.7.0", - "prettier": "^3.0.3", - "prisma": "^5.5.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.1", - "ts-loader": "^9.5.0", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.2.2" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - } + "name": "nestjs-chat", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "node dist/main", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.454.0", + "@aws-sdk/s3-request-presigner": "^3.468.0", + "@nestjs/common": "^10.2.7", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.2.7", + "@nestjs/jwt": "^10.1.1", + "@nestjs/passport": "^10.0.2", + "@nestjs/platform-express": "^10.2.7", + "@nestjs/platform-socket.io": "^10.2.7", + "@nestjs/websockets": "^10.2.7", + "@paralleldrive/cuid2": "^2.2.2", + "@prisma/client": "^5.16.1", + "@types/bcrypt": "^5.0.1", + "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "dotenv": "^16.3.1", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.1.13", + "resend": "^1.1.0", + "rxjs": "^7.8.1", + "socket.io": "^4.7.2", + "stripe": "^14.13.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.2.0", + "@nestjs/schematics": "^10.0.2", + "@nestjs/testing": "^10.2.7", + "@types/express": "^4.17.20", + "@types/jest": "^29.5.6", + "@types/multer": "^1.4.11", + "@types/node": "^20.8.8", + "@types/supertest": "^2.0.15", + "@typescript-eslint/eslint-plugin": "^6.9.0", + "@typescript-eslint/parser": "^6.9.0", + "eslint": "^8.52.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "jest": "^29.7.0", + "prettier": "^3.0.3", + "prisma": "^5.16.1", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.2.2" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } } diff --git a/prisma/migrations/20240707171540_init/migration.sql b/prisma/migrations/20240707171540_init/migration.sql new file mode 100644 index 0000000..5a68473 --- /dev/null +++ b/prisma/migrations/20240707171540_init/migration.sql @@ -0,0 +1,107 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "firstName" TEXT, + "password" TEXT NOT NULL, + "isResettingPassword" BOOLEAN NOT NULL DEFAULT false, + "resetPasswordToken" TEXT, + "avatarFileKey" TEXT, + "stripeAccountId" TEXT, + "stripeProductId" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Conversation" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatMessage" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "senderId" TEXT NOT NULL, + "chatId" TEXT NOT NULL, + + CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Donation" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "stripeProductId" TEXT NOT NULL, + "stripePriceId" TEXT NOT NULL, + "givingUserId" TEXT NOT NULL, + "receivingUserId" TEXT NOT NULL, + "amount" INTEGER, + + CONSTRAINT "Donation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ConversationToUser" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_resetPasswordToken_key" ON "User"("resetPasswordToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_avatarFileKey_key" ON "User"("avatarFileKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeAccountId_key" ON "User"("stripeAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_stripeProductId_key" ON "User"("stripeProductId"); + +-- CreateIndex +CREATE INDEX "ChatMessage_chatId_idx" ON "ChatMessage"("chatId"); + +-- CreateIndex +CREATE INDEX "ChatMessage_senderId_idx" ON "ChatMessage"("senderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Donation_stripePriceId_key" ON "Donation"("stripePriceId"); + +-- CreateIndex +CREATE INDEX "Donation_givingUserId_idx" ON "Donation"("givingUserId"); + +-- CreateIndex +CREATE INDEX "Donation_receivingUserId_idx" ON "Donation"("receivingUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ConversationToUser_AB_unique" ON "_ConversationToUser"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ConversationToUser_B_index" ON "_ConversationToUser"("B"); + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Donation" ADD CONSTRAINT "Donation_givingUserId_fkey" FOREIGN KEY ("givingUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Donation" ADD CONSTRAINT "Donation_receivingUserId_fkey" FOREIGN KEY ("receivingUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ConversationToUser" ADD CONSTRAINT "_ConversationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ConversationToUser" ADD CONSTRAINT "_ConversationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60cfe4f..0f81dfa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,9 +2,8 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema datasource db { - provider = "mysql" - url = env("DATABASE_URL") - relationMode = "prisma" + provider = "postgresql" + url = env("DATABASE_URL") } generator client { @@ -12,35 +11,58 @@ generator client { } model User { - id String @id @default(cuid()) - email String @unique - firstName String? - password String + id String @id @default(cuid()) + email String @unique + firstName String? + password String isResettingPassword Boolean @default(false) - resetPasswordToken String? @unique - avatarFileKey String? @unique + resetPasswordToken String? @unique + avatarFileKey String? @unique - conversations Conversation[] - sentMessages ChatMessage[] + conversations Conversation[] + sentMessages ChatMessage[] + stripeAccountId String? @unique + stripeProductId String? @unique + + givenDonations Donation[] @relation("givingUser") + receivedDonations Donation[] @relation("receivingUser") } model Conversation { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - users User[] - messages ChatMessage[] + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + messages ChatMessage[] } model ChatMessage { - id String @id @default(cuid()) - content String - createdAt DateTime @default(now()) - sender User @relation(fields: [senderId], references: [id]) - senderId String + id String @id @default(cuid()) + content String + createdAt DateTime @default(now()) + sender User @relation(fields: [senderId], references: [id]) + senderId String conversation Conversation @relation(fields: [chatId], references: [id], onDelete: Cascade, onUpdate: Cascade) - chatId String + chatId String @@index([chatId]) @@index([senderId]) -} \ No newline at end of file +} + +model Donation { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + stripeProductId String + stripePriceId String @unique + + givingUser User @relation("givingUser", fields: [givingUserId], references: [id]) + givingUserId String + + receivingUser User @relation("receivingUser", fields: [receivingUserId], references: [id]) + receivingUserId String + // Amount is in cents + amount Int? + + @@index([givingUserId]) + @@index([receivingUserId]) +} diff --git a/src/app.module.ts b/src/app.module.ts index f0ae9a7..1613f76 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { AppGateway } from './app.gateway'; import { AuthModule } from './auth/auth.module'; import { ChatModule } from './chat/chat.module'; import { SocketModule } from './socket/socket.module'; +import { StripeModule } from './stripe/stripe.module'; import { UserModule } from './user/user.module'; @Module({ @@ -15,6 +16,7 @@ import { UserModule } from './user/user.module'; AuthModule, ChatModule, SocketModule, + StripeModule, ], controllers: [], providers: [AppGateway], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index fda97fc..1ed0ba5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt'; import { AwsS3Service } from 'src/aws/aws-s3.service'; import { MailerService } from 'src/mailer.service'; import { PrismaService } from 'src/prisma.service'; +import { StripeService } from 'src/stripe/stripe.service'; import { UserService } from 'src/user/user.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -24,6 +25,7 @@ import { JwtStrategy } from './jwt.strategy'; UserService, MailerService, AwsS3Service, + StripeService, ], }) export class AuthModule {} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 295b931..3f138ab 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -245,6 +245,26 @@ export class ChatService { firstName: true, id: true, avatarFileKey: true, + receivedDonations: { + select: { + amount: true, + id: true, + createdAt: true, + }, + where: { + givingUserId: existingUser.id, + }, + }, + givenDonations: { + select: { + amount: true, + id: true, + createdAt: true, + }, + where: { + receivingUserId: existingUser.id, + }, + }, }, }, messages: { diff --git a/src/stripe/stripe.controller.ts b/src/stripe/stripe.controller.ts new file mode 100644 index 0000000..e418644 --- /dev/null +++ b/src/stripe/stripe.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Param, + Post, + RawBodyRequest, + Request, + UseGuards, +} from '@nestjs/common'; +import { type Request as RequestType } from 'express'; +import { JwtAuthGuard } from 'src/auth/jwt-auth.guard'; +import { RequestWithUser } from 'src/auth/jwt.strategy'; +import { StripeService } from './stripe.service'; + +@Controller('stripe') +export class StripeController { + constructor(private readonly stripe: StripeService) {} + + @UseGuards(JwtAuthGuard) + @Post('connect') + // localhost:3000/stripe/connect POST + async getConversations( + @Request() request: RequestWithUser, + ): Promise<{ accountLink: string }> { + return await this.stripe.createConnectedAccount({ + userId: request.user.userId, + }); + } + + @UseGuards(JwtAuthGuard) + @Post('donate/:receivingUserId') + // localhost:3000/stripe/donate/id POST + async createDonation( + @Param('receivingUserId') receivingUserId: string, + @Request() request: RequestWithUser, + ): Promise<{ error: boolean; message: string; sessionUrl: string | null }> { + return await this.stripe.createDonation({ + givingUserId: request.user.userId, + receivingUserId, + }); + } + + @Post('webhook') + // localhost:3000/stripe/donate/id POST + async handleWebhooks(@Request() request: RawBodyRequest) { + return await this.stripe.handleWebhooks({ request }); + } +} diff --git a/src/stripe/stripe.module.ts b/src/stripe/stripe.module.ts new file mode 100644 index 0000000..581479f --- /dev/null +++ b/src/stripe/stripe.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/prisma.service'; +import { StripeController } from './stripe.controller'; +import { StripeService } from './stripe.service'; + +@Module({ + controllers: [StripeController], + providers: [StripeService, PrismaService], +}) +export class StripeModule {} diff --git a/src/stripe/stripe.service.ts b/src/stripe/stripe.service.ts new file mode 100644 index 0000000..9e42881 --- /dev/null +++ b/src/stripe/stripe.service.ts @@ -0,0 +1,261 @@ +import { Injectable, RawBodyRequest } from '@nestjs/common'; +import { Request } from 'express'; +import { PrismaService } from 'src/prisma.service'; +import Stripe from 'stripe'; + +@Injectable() +export class StripeService { + private readonly stripe: Stripe; + + constructor(private readonly prisma: PrismaService) { + this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2023-10-16', + }); + } + + async createConnectedAccount({ + userId, + }: { + userId: string; + }): Promise<{ accountLink: string }> { + const existingUser = await this.prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + id: true, + stripeAccountId: true, + email: true, + }, + }); + + if (existingUser.stripeAccountId) { + const accountLink = await this.createAccountLink({ + stripeAccountId: existingUser.stripeAccountId, + }); + return { accountLink: accountLink.url }; + } + + const stripeAccount = await this.stripe.accounts.create({ + type: 'express', + email: existingUser.email, + default_currency: 'EUR', + }); + + await this.prisma.user.update({ + where: { + id: existingUser.id, + }, + data: { + stripeAccountId: stripeAccount.id, + }, + }); + + const accountLink = await this.createAccountLink({ + stripeAccountId: stripeAccount.id, + }); + + return { accountLink: accountLink.url }; + } + + async createAccountLink({ stripeAccountId }: { stripeAccountId: string }) { + return await this.stripe.accountLinks.create({ + account: stripeAccountId, + refresh_url: 'http://localhost:3000/onboarding', + return_url: 'http://localhost:3000', + type: 'account_onboarding', + }); + } + + async getStripeAccount({ stripeAccountId }: { stripeAccountId: string }) { + const stripeAccount = await this.stripe.accounts.retrieve(stripeAccountId); + const canReceiveMoney = stripeAccount.charges_enabled; + + return { stripeAccount, canReceiveMoney }; + } + + async createDonation({ + receivingUserId, + givingUserId, + }: { + receivingUserId: string; + givingUserId: string; + }): Promise<{ error: boolean; message: string; sessionUrl: string | null }> { + try { + if (receivingUserId === givingUserId) { + throw new Error('Vous ne pouvez pas vous faire de dons Γ  vous-mΓͺme'); + } + const [receivingUser, givingUser] = await Promise.all([ + this.prisma.user.findUniqueOrThrow({ + where: { + id: receivingUserId, + }, + select: { + id: true, + firstName: true, + stripeProductId: true, + stripeAccountId: true, + }, + }), + this.prisma.user.findUniqueOrThrow({ + where: { + id: givingUserId, + }, + select: { + id: true, + stripeAccountId: true, + }, + }), + ]); + + if (!receivingUser.stripeAccountId) { + throw new Error( + "L'utilisateur recevant le don n'a pas de compte Stripe et ne peut pas recevoir de dons", + ); + } + if (!givingUser.stripeAccountId) { + throw new Error( + "L'utilisateur envoyant le don n'a pas de compte Stripe et ne peut pas recevoir de dons", + ); + } + const stripeAccount = await this.stripe.accounts.retrieve( + receivingUser.stripeAccountId, + ); + + let { stripeProductId } = receivingUser; + + if (!stripeProductId) { + const product = await this.stripe.products.create({ + name: `Soutenez ${receivingUser.firstName}`, + }); + + await this.prisma.user.update({ + where: { + id: receivingUser.id, + }, + data: { + stripeProductId: product.id, + }, + }); + stripeProductId = product.id; + } + + const price = await this.stripe.prices.create({ + currency: 'EUR', + custom_unit_amount: { + enabled: true, + }, + product: stripeProductId, + }); + + const createdDonation = await this.prisma.donation.create({ + data: { + stripePriceId: price.id, + stripeProductId: stripeProductId, + receivingUser: { + connect: { + id: givingUser.id, + }, + }, + givingUser: { + connect: { + id: receivingUser.id, + }, + }, + }, + }); + + const session = await this.stripe.checkout.sessions.create({ + mode: 'payment', + line_items: [ + { + price: price.id, + quantity: 1, + }, + ], + payment_intent_data: { + application_fee_amount: 0, + metadata: { + donationId: createdDonation.id, + }, + transfer_data: { + destination: stripeAccount.id, + }, + }, + success_url: 'http://localhost:3000', + cancel_url: 'http://localhost:3000', + }); + + return { + sessionUrl: session.url, + error: false, + message: 'La session a bien Γ©tΓ© créée.', + }; + } catch (error) { + if (error instanceof Error) { + return { + error: true, + message: error.message, + sessionUrl: null, + }; + } + } + } + + async handleWebhooks({ + request, + }: { + request: RawBodyRequest; + }): Promise<{ error: boolean; message: string }> { + try { + const sig = request.headers['stripe-signature']; + + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!endpointSecret) { + throw new Error('The endpoint secret is not defined'); + } + + const event = this.stripe.webhooks.constructEvent( + request.rawBody, + sig, + endpointSecret, + ); + + // Handle the event + switch (event.type) { + case 'payment_intent.succeeded': + const paymentIntentSucceeded = event.data.object; + // Then define and call a function to handle the event payment_intent.succeeded + const amount = paymentIntentSucceeded.amount; + const donationId = paymentIntentSucceeded.metadata.donationId; + + console.log({ donationId, amount }); + await this.prisma.donation.update({ + where: { + id: donationId, + }, + data: { + amount: amount, + }, + }); + + break; + // ... handle other event types + default: + console.log(`Unhandled event type ${event.type}`); + } + + // Return a 200 response to acknowledge receipt of the event + return { + error: false, + message: 'Webhook handled successfully', + }; + } catch (err) { + console.error(`Webhook Error: ${err.message}`); + return { + error: true, + message: `Webhook Error: ${err.message}`, + }; + } + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index d67d6d1..162c725 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AwsS3Service } from 'src/aws/aws-s3.service'; import { PrismaService } from 'src/prisma.service'; +import { StripeService } from 'src/stripe/stripe.service'; import { UserController } from './user.controller'; import { UserService } from './user.service'; @Module({ controllers: [UserController], - providers: [UserService, PrismaService, AwsS3Service], + providers: [UserService, PrismaService, AwsS3Service, StripeService], }) export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 73c5aa7..2c2248a 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AwsS3Service } from 'src/aws/aws-s3.service'; import { fileSchema } from 'src/file-utils'; import { PrismaService } from 'src/prisma.service'; +import { StripeService } from 'src/stripe/stripe.service'; import { z } from 'zod'; @Injectable() @@ -9,6 +10,7 @@ export class UserService { constructor( private readonly prisma: PrismaService, private readonly awsS3Service: AwsS3Service, + private readonly stripe: StripeService, ) {} async getUsers() { const users = await this.prisma.user.findMany({ @@ -45,6 +47,7 @@ export class UserService { email: true, firstName: true, avatarFileKey: true, + stripeAccountId: true, }, }); let avatarUrl = ''; @@ -53,7 +56,14 @@ export class UserService { fileKey: user.avatarFileKey, }); } - return { ...user, avatarUrl }; + let canReceiveMoney = false; + if (user.stripeAccountId) { + const stripeAccountData = await this.stripe.getStripeAccount({ + stripeAccountId: user.stripeAccountId, + }); + canReceiveMoney = stripeAccountData.canReceiveMoney; + } + return { ...user, avatarUrl, canReceiveMoney }; } async updateUser({ diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..3f30e76 --- /dev/null +++ b/start.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +set -ex +npx prisma migrate deploy +npm run start \ No newline at end of file