diff --git a/.env.sample b/.env.sample index 81165b28..be13e163 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,4 @@ -POSTGRES_USER="" -POSTGRES_PASSWORD="" -POSTGRES_DB="" -NEXT_PUBLIC_API_TOKEN="" \ No newline at end of file +NEXT_PUBLIC_API_TOKEN="" +JWT_SECRET="" +NODE_ENV="" +NEXT_PUBLIC_API_URL="" \ No newline at end of file diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 1c342db0..5204c01c 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -1,72 +1,74 @@ -# .github/workflows/dev-client.yml name: Compile and push profile APP staging -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the master branch pull_request: branches: ["dev"] types: - closed - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" - build: - # if pull request merged - if: github.event.pull_request.merged == true - - # The type of runner that the job will run on + check-build: + if: github.event.pull_request.merged == true runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Vérifier que Docker Compose fonctionne + run: docker compose version + + - name: Tester le build des services + run: | + docker compose -f docker-compose.yml build + continue-on-error: false # Stoppe le workflow si le build échoue - # Steps represent a sequence of tasks that will be executed as part of the job + check-webhook: + needs: check-build # Exécute cette étape seulement si le build a réussi + runs-on: ubuntu-latest steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 + - name: Vérifier l'accès au webhook + id: check_webhook + continue-on-error: true + run: | + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}\n" ${{ secrets.WEBHOOK_URL_MAIN }}) + echo "Webhook status: $HTTP_STATUS" + if [[ "$HTTP_STATUS" -ne 200 ]]; then + echo "Erreur : Le webhook ne répond pas correctement (HTTP $HTTP_STATUS)." + exit 1 + fi + #Deactivation due to new Docker Hub conditions + # - uses: actions/checkout@v4 - # login with Docker - - uses: docker/login-action@v2 - name: Login to Docker Hub - with: - # generate some credentials from Dockerhub and store them into the repo secrets - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + # - uses: docker/login-action@v2 + # name: Login to Docker Hub + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} - # prepare buildx for docker - - uses: docker/setup-buildx-action@v2 - name: Set up Docker Build + # - uses: docker/setup-buildx-action@v2 + # name: Set up Docker Build - # build an push the newly created image frontend - - uses: docker/build-push-action@v4 - name: Build and push frontend - with: - context: ./frontend - file: ./frontend/Dockerfile - push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev-frontend-portfolio:latest + # - uses: docker/build-push-action@v4 + # name: Build and push frontend + # with: + # context: ./frontend + # file: ./frontend/Dockerfile + # push: true + # tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev-frontend-portfolio:latest + # secrets: | + # NEXT_PUBLIC_API_TOKEN=${{ secrets.NEXT_PUBLIC_API_TOKEN }} - # build an push the newly created image backend - - uses: docker/build-push-action@v4 - name: Build and push backend - with: - context: ./backend - file: ./backend/Dockerfile - push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev-backend-portfolio:latest + # - uses: docker/build-push-action@v4 + # name: Build and push backend + # with: + # context: ./backend + # file: ./backend/Dockerfile + # push: true + # tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev-backend-portfolio:latest - # deployment: - # needs: build - # runs-on: ubuntu-latest - # # send deploiement hook - # steps: - # - name: Invoke deployment hook - # uses: distributhor/workflow-webhook@v3 - # with: - # webhook_url: ${{ secrets.WEBHOOK_URL_DEV }} deployment: - needs: build + needs: [check-build, check-webhook] runs-on: ubuntu-latest steps: - name: Invoke deployment hook @@ -77,4 +79,4 @@ jobs: SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha1 -hmac "$SECRET_KEY" | sed 's/^.* //') SIGNATURE="sha1=$SIGNATURE" - curl -X POST -H "Content-Type: application/json" -H "X-Hub-Signature: $SIGNATURE" -d "$PAYLOAD" ${{ secrets.WEBHOOK_URL_DEV }} \ No newline at end of file + curl -X POST -H "Content-Type: application/json" -H "X-Hub-Signature: $SIGNATURE" -d "$PAYLOAD" ${{ secrets.WEBHOOK_URL_DEV }} diff --git a/.github/workflows/main-cd.yml b/.github/workflows/main-cd.yml index e9553cf7..265d5d52 100644 --- a/.github/workflows/main-cd.yml +++ b/.github/workflows/main-cd.yml @@ -1,60 +1,68 @@ -# .github/workflows/staging-client.yml name: Compile and push profile APP staging -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the master branch pull_request: branches: ["main"] types: - closed - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" - build: - # if pull request merged - if: github.event.pull_request.merged == true - - # The type of runner that the job will run on + check-build: + if: github.event.pull_request.merged == true runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Vérifier que Docker Compose fonctionne + run: docker compose version + + - name: Tester le build des services + run: | + docker compose -f docker-compose.yml build + continue-on-error: false # Stoppe le workflow si le build échoue - # Steps represent a sequence of tasks that will be executed as part of the job + check-webhook: + needs: check-build # Exécute cette étape seulement si le build a réussi + runs-on: ubuntu-latest steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v4 + - name: Vérifier l'accès au webhook + id: check_webhook + continue-on-error: true + run: | + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}\n" ${{ secrets.WEBHOOK_URL_MAIN }}) + echo "Webhook status: $HTTP_STATUS" + if [[ "$HTTP_STATUS" -ne 200 ]]; then + echo "Erreur : Le webhook ne répond pas correctement (HTTP $HTTP_STATUS)." + exit 1 + fi - # login with Docker - - uses: docker/login-action@v2 - name: Login to Docker Hub - with: - # generate some credentials from Dockerhub and store them into the repo secrets - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + #Deactivation due to new Docker Hub conditions + # - uses: docker/login-action@v2 + # name: Login to Docker Hub + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} - # prepare buildx for docker - - uses: docker/setup-buildx-action@v2 - name: Set up Docker Build + # - uses: docker/setup-buildx-action@v2 + # name: Set up Docker Build - # build an push the newly created image frontend - - uses: docker/build-push-action@v4 - name: Build and push frontend - with: - context: ./frontend - file: ./frontend/Dockerfile - push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/main-frontend-portfolio:latest + # - uses: docker/build-push-action@v4 + # name: Build and push frontend + # with: + # context: ./frontend + # file: ./frontend/Dockerfile + # push: true + # tags: ${{ secrets.DOCKERHUB_USERNAME }}/main-frontend-portfolio:latest - # build an push the newly created image backend - - uses: docker/build-push-action@v4 - name: Build and push backend - with: - context: ./backend - file: ./backend/Dockerfile - push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/main-backend-portfolio:latest + # - uses: docker/build-push-action@v4 + # name: Build and push backend + # with: + # context: ./backend + # file: ./backend/Dockerfile + # push: true + # tags: ${{ secrets.DOCKERHUB_USERNAME }}/main-backend-portfolio:latest # deployment: # needs: build @@ -66,7 +74,7 @@ jobs: # with: # webhook_url: ${{ secrets.WEBHOOK_URL_MAIN }} deployment: - needs: build + needs: [check-build, check-webhook] runs-on: ubuntu-latest steps: - name: Invoke deployment hook diff --git a/.gitignore b/.gitignore index 382309e3..307ff582 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode node_modules -.env \ No newline at end of file +.env +mysql-data \ No newline at end of file diff --git a/backend/.env.sample b/backend/.env.sample index da5d3e75..16219713 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -8,4 +8,26 @@ PORT_MAIL="" AUTH_USER_MAIL="" AUTH_PASS_MAIL="" API_KEY="" -BASE_URL="" \ No newline at end of file +BASE_URL="" +JWT_SECRET="" +NODE_ENV="" + +# This was inserted by `prisma init`: +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +# The following `prisma+postgres` URL is similar to the URL produced by running a local Prisma Postgres +# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the +# one found in a remote Prisma Postgres URL, does not contain any sensitive information. + +DATABASE_URL="" + +# url d'exemple : +# version windows +# DATABASE_URL="mysql://user:password@host.docker.internal:3306/bddname" + +#pour linux +# DATABASE_URL="mysql://user:password@172.17.0.1:3306/bddname" \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..126419de --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/src/generated/prisma diff --git a/backend/Dockerfile b/backend/Dockerfile index 22660382..63699694 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,5 @@ -FROM node:16-alpine3.14 +# FROM node:16-alpine3.14 +FROM node:18.18-alpine RUN apk --no-cache add \ build-base \ @@ -9,6 +10,7 @@ RUN apk --no-cache add \ musl-dev \ giflib-dev \ librsvg-dev \ + mysql-client \ python3 RUN mkdir /app @@ -16,8 +18,14 @@ WORKDIR /app COPY package.json . RUN npm i +COPY src/prisma ./src/prisma +RUN npx prisma generate --schema=src/prisma/schema.prisma + COPY src src -COPY codegen.ts . +COPY tests tests +COPY jest.config.ts jest.config.ts + +# COPY codegen.ts . COPY tsconfig.json . diff --git a/backend/codegen.ts b/backend/codegen.ts deleted file mode 100644 index ec1e049f..00000000 --- a/backend/codegen.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CodegenConfig } from '@graphql-codegen/cli'; - -const config: CodegenConfig = { - schema: "http://localhost:4000/graphql", - generates: { - './src/types/resolvers-types.ts': { - config: { - useIndexSignature: true, - // maybeValue: "T | undefined", - }, - plugins: ['typescript', 'typescript-resolvers'], - }, - }, - // debug : true, - verbose : true -}; -export default config; \ No newline at end of file diff --git a/backend/codegen.yml b/backend/codegen.yml new file mode 100644 index 00000000..3ce6a44f --- /dev/null +++ b/backend/codegen.yml @@ -0,0 +1,14 @@ +verbose: true + +schema: + - http://localhost:4000/graphql: + headers: + x-api-key: "${API_KEY}" + +generates: + ./src/types/graphql.ts: + plugins: + - typescript + - typescript-operations + config: + useIndexSignature: true \ No newline at end of file diff --git a/backend/jest.config.ts b/backend/jest.config.ts new file mode 100644 index 00000000..63a73c71 --- /dev/null +++ b/backend/jest.config.ts @@ -0,0 +1,8 @@ +export default { + preset: "ts-jest", + testEnvironment: "node", + moduleFileExtensions: ["ts", "js", "json"], + testMatch: ["**/tests/**/*.test.ts"], + setupFiles: ["dotenv/config"], + transformIgnorePatterns: ["/node_modules/"], +}; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0f73156d..eb67ed98 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@graphql-tools/load-files": "^7.0.0", "@graphql-tools/merge": "^9.0.1", "@parcel/watcher": "^2.4.1", + "@prisma/client": "^6.9.0", "@types/multer": "^1.4.11", "argon2": "^0.31.2", "canvas": "^2.11.2", @@ -21,13 +22,16 @@ "graphql": "^16.8.1", "graphql-scalars": "^1.22.4", "jose": "^5.2.3", + "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.14", "pg": "^8.11.3", "reflect-metadata": "^0.2.1", + "simple-icons": "^15.1.0", + "supertest": "^7.1.1", "ts-node-dev": "^2.0.0", "type-graphql": "^2.0.0-beta.3", - "typeorm": "^0.3.20", + "typeorm": "^0.3.11", "uuid": "^10.0.0", "uuidv4": "^6.2.13" }, @@ -38,12 +42,17 @@ "@types/cookies": "^0.9.0", "@types/cors": "^2.8.17", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.11.15", "@types/nodemailer": "^6.4.15", + "@types/supertest": "^6.0.3", "class-validator": "^0.14.1", + "dotenv-cli": "^8.0.0", "jest": "^29.7.0", + "jest-mock-extended": "^4.0.0-beta1", + "prisma": "^6.9.0", "ts-jest": "^29.1.2", - "typeorm-fixtures-cli": "^4.0.0", + "typeorm-fixtures-cli": "^2.0.0", "typescript": "^5.3.3" } }, @@ -393,13 +402,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -707,19 +718,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -734,100 +747,28 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", - "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@babel/types": "^7.27.3" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -1400,26 +1341,24 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", - "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1470,14 +1409,14 @@ "dev": true }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1510,19 +1449,14 @@ } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-6.3.1.tgz", + "integrity": "sha512-8YXBE2ZcU/pImVOHX7MWrSR/X5up7t6rPWZlk34RwZEcdr3ua6X+32pSd6XuOQRN+vbuvYNfA6iey8NbrjuMFQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=14.0.0", + "npm": ">=6.0.0" } }, "node_modules/@graphql-codegen/add": { @@ -3124,95 +3058,6 @@ "@hapi/hoek": "^9.0.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3677,6 +3522,18 @@ "node": ">=10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3709,6 +3566,15 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz", @@ -4016,13 +3882,96 @@ "node": ">=10" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, + "node_modules/@prisma/client": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.9.0.tgz", + "integrity": "sha512-Gg7j1hwy3SgF1KHrh0PZsYvAaykeR0PaxusnLXydehS96voYCGt1U5zVR31NIouYc63hWzidcrir1a7AIyCsNQ==", + "hasInstallScript": true, + "license": "Apache-2.0", "engines": { - "node": ">=14" + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.9.0.tgz", + "integrity": "sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/config/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@prisma/debug": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.9.0.tgz", + "integrity": "sha512-bFeur/qi/Q+Mqk4JdQ3R38upSYPebv5aOyD1RKywVD+rAMLtRkmTFn28ZuTtVOnZHEdtxnNOCH+bPIeSGz1+Fg==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.9.0.tgz", + "integrity": "sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0", + "@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "@prisma/fetch-engine": "6.9.0", + "@prisma/get-platform": "6.9.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e.tgz", + "integrity": "sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.9.0.tgz", + "integrity": "sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0", + "@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "@prisma/get-platform": "6.9.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.9.0.tgz", + "integrity": "sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0" } }, "node_modules/@protobufjs/aspromise": { @@ -4212,6 +4161,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookies": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", @@ -4309,6 +4265,17 @@ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -4320,11 +4287,25 @@ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/multer": { "version": "1.4.11", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", @@ -4409,6 +4390,30 @@ "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -4603,7 +4608,8 @@ "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", @@ -4658,6 +4664,7 @@ "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.31.2.tgz", "integrity": "sha512-QSnJ8By5Mth60IEte45w9Y7v6bWcQw3YhRtJKKN8oNCxnTLDiv/AXXkDPf2srTMfxFVn3QJdVv2nhXESsUa+Yg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@mapbox/node-pre-gyp": "^1.0.11", "@phc/format": "^1.0.0", @@ -4670,8 +4677,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -4689,8 +4695,7 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, "node_modules/asn1js": { "version": "3.0.5", @@ -4953,9 +4958,10 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -4965,7 +4971,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -5072,6 +5078,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5092,6 +5104,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5379,6 +5392,7 @@ "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", @@ -5399,6 +5413,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -5409,6 +5424,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -5425,6 +5441,7 @@ "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -5442,6 +5459,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", "engines": { "node": ">=10" } @@ -5590,6 +5608,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -5599,6 +5627,15 @@ "node": ">=4.0.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5676,6 +5713,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5687,9 +5725,10 @@ "dev": true }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5699,6 +5738,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "license": "MIT" + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -5801,9 +5846,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5819,10 +5866,21 @@ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==", "dev": true }, - "node_modules/dayjs": { - "version": "1.11.11", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", - "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } }, "node_modules/debounce": { "version": "1.2.1", @@ -5834,6 +5892,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -5859,10 +5918,11 @@ } }, "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", "dev": true, + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -5955,6 +6015,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -5989,6 +6050,16 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -6028,9 +6099,10 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -6038,10 +6110,37 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/dset": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", "engines": { "node": ">=4" } @@ -6054,15 +6153,20 @@ "xtend": "^4.0.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, "node_modules/ejs": { "version": "3.1.10", @@ -6103,9 +6207,10 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6149,7 +6254,8 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "1.0.5", @@ -6177,19 +6283,11 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6239,36 +6337,37 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -6277,7 +6376,11 @@ }, "engines": { "node": ">= 0.10.0" - } + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/external-editor": { "version": "3.1.0", @@ -6341,6 +6444,12 @@ "fast-decode-uri-component": "^1.0.1" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-url-parser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", @@ -6413,10 +6522,11 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -6445,12 +6555,13 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6474,32 +6585,6 @@ "node": ">=8" } }, - "node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -6513,6 +6598,23 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6525,6 +6627,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6738,48 +6841,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/graphology": { - "version": "0.25.4", - "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.25.4.tgz", - "integrity": "sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==", - "dev": true, - "dependencies": { - "events": "^3.3.0", - "obliterator": "^2.0.2" - }, - "peerDependencies": { - "graphology-types": ">=0.24.0" - } - }, - "node_modules/graphology-dag": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/graphology-dag/-/graphology-dag-0.3.0.tgz", - "integrity": "sha512-dg4JPb+/LDEbDinZIj7ezWzlEXDRokshdpTL8oAuftE9Uy0uTKGOKSmYULY8p3j/vw0HB31Wog9T/kpqprUQpg==", - "dev": true, - "dependencies": { - "graphology-utils": "^2.4.1", - "mnemonist": "^0.39.0" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-types": { - "version": "0.24.7", - "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.7.tgz", - "integrity": "sha512-tdcqOOpwArNjEr0gNQKCXwaNCWnQJrog14nJNQPeemcLnXQUUGrsCWpWkVKt46zLjcS6/KGoayeJfHHyPDlvwA==", - "dev": true, - "peer": true - }, - "node_modules/graphology-utils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", - "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", - "dev": true, - "peerDependencies": { - "graphology-types": ">=0.23.0" - } - }, "node_modules/graphql": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", @@ -6988,6 +7049,7 @@ "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", "engines": { "node": "*" } @@ -7002,6 +7064,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -7453,7 +7516,8 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/isomorphic-ws": { "version": "5.0.0", @@ -7592,23 +7656,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", - "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", @@ -7932,6 +7979,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "4.0.0-beta1", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-4.0.0-beta1.tgz", + "integrity": "sha512-MYcI0wQu3ceNhqKoqAJOdEfsVMamAFqDTjoLN5Y45PAG3iIm4WGnhOu0wpMjlWCexVPO71PMoNir9QrGXrnIlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.2" + }, + "peerDependencies": { + "@jest/globals": "^28.0.0 || ^29.0.0", + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -8234,7 +8296,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -8285,6 +8346,67 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -8393,12 +8515,54 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -8565,9 +8729,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -8609,9 +8777,10 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8624,6 +8793,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -8731,19 +8901,11 @@ "node": ">=10" } }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "dev": true, - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/multer": { "version": "1.4.5-lts.1", @@ -8783,6 +8945,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -8941,16 +9104,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==", - "dev": true - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -9088,11 +9246,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" - }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -9150,12 +9303,14 @@ "node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT" }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", "dependencies": { "parse5": "^6.0.1" } @@ -9163,12 +9318,14 @@ "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -9214,6 +9371,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -9244,33 +9402,11 @@ "node": ">=0.10.0" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "engines": { - "node": "14 || >=16.14" - } - }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -9371,10 +9507,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -9469,6 +9606,32 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.9.0.tgz", + "integrity": "sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.9.0", + "@prisma/engines": "6.9.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9549,11 +9712,12 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -9585,6 +9749,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9593,6 +9758,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -9638,12 +9804,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, "node_modules/relay-runtime": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz", @@ -9850,6 +10010,12 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/scuid": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", @@ -9865,9 +10031,10 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -9887,10 +10054,20 @@ "node": ">= 0.8.0" } }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/sentence-case": { "version": "3.0.4", @@ -9904,14 +10081,15 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -9947,7 +10125,8 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, "node_modules/sha.js": { "version": "2.4.11", @@ -9965,6 +10144,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -9976,6 +10156,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -10046,6 +10227,25 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-icons": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.1.0.tgz", + "integrity": "sha512-7wj0OeOJvGSgNEIrMm1MGhoX7CEV5R+T0Q5HmXzfakrue84qCZNV9l9eG092c14BbiuGgljdwFkLy3rQQaDJyg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10149,6 +10349,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -10201,20 +10402,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10226,18 +10413,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -10268,6 +10443,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz", + "integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", + "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10333,6 +10576,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", "dependencies": { "any-promise": "^1.0.0" } @@ -10341,6 +10585,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -10381,15 +10626,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10405,6 +10641,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { "node": ">=0.6" } @@ -10422,6 +10659,21 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-essentials": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.0.tgz", + "integrity": "sha512-LirrVzbhIpFQ9BdGfqLnM9r7aP9rnyfeoxbP5ZEkdr531IaY21+KdebRSsbvqu28VDJtcDDn+AlGn95t0c52zQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-jest": { "version": "29.1.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.5.tgz", @@ -10700,25 +10952,28 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typeorm": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", - "integrity": "sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.11.tgz", + "integrity": "sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==", + "license": "MIT", "dependencies": { - "@sqltools/formatter": "^1.2.5", - "app-root-path": "^3.1.0", + "@sqltools/formatter": "^1.2.2", + "app-root-path": "^3.0.0", "buffer": "^6.0.3", - "chalk": "^4.1.2", + "chalk": "^4.1.0", "cli-highlight": "^2.1.11", - "dayjs": "^1.11.9", - "debug": "^4.3.4", - "dotenv": "^16.0.3", - "glob": "^10.3.10", - "mkdirp": "^2.1.3", - "reflect-metadata": "^0.2.1", + "date-fns": "^2.28.0", + "debug": "^4.3.3", + "dotenv": "^16.0.0", + "glob": "^7.2.0", + "js-yaml": "^4.1.0", + "mkdirp": "^1.0.4", + "reflect-metadata": "^0.1.13", "sha.js": "^2.4.11", - "tslib": "^2.5.0", - "uuid": "^9.0.0", - "yargs": "^17.6.2" + "tslib": "^2.3.1", + "uuid": "^8.3.2", + "xml2js": "^0.4.23", + "yargs": "^17.3.1" }, "bin": { "typeorm": "cli.js", @@ -10726,7 +10981,7 @@ "typeorm-ts-node-esm": "cli-ts-node-esm.js" }, "engines": { - "node": ">=16.13.0" + "node": ">= 12.9.0" }, "funding": { "url": "https://opencollective.com/typeorm" @@ -10734,13 +10989,13 @@ "peerDependencies": { "@google-cloud/spanner": "^5.18.0", "@sap/hana-client": "^2.12.25", - "better-sqlite3": "^7.1.2 || ^8.0.0 || ^9.0.0", + "better-sqlite3": "^7.1.2 || ^8.0.0", "hdb-pool": "^0.1.6", "ioredis": "^5.0.4", - "mongodb": "^5.8.0", - "mssql": "^9.1.1 || ^10.0.1", - "mysql2": "^2.2.5 || ^3.0.1", - "oracledb": "^6.3.0", + "mongodb": "^3.6.0", + "mssql": "^7.3.0", + "mysql2": "^2.2.5", + "oracledb": "^5.1.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", @@ -10805,78 +11060,58 @@ } }, "node_modules/typeorm-fixtures-cli": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/typeorm-fixtures-cli/-/typeorm-fixtures-cli-4.0.0.tgz", - "integrity": "sha512-AHymSkNQngnUG2dUwg0249H6v3WLASPzfvJUWjA8C6qYWKAEqJdZehjmvkzXQrKW4zwBs4JgEuq7c9OhpqOLrQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typeorm-fixtures-cli/-/typeorm-fixtures-cli-2.0.0.tgz", + "integrity": "sha512-pQBnuKetB6jI8HmC108Q28FRE5KC+do266E+mZxLe5PaKoxz5ppOWuEKl30oCzvScNZUv1gbOe+1lZqlexCQYQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@faker-js/faker": ">=7.4.0", + "@faker-js/faker": "^6.0.0", "chai": "^4.2.0", "chalk": "^4.0.0", "class-transformer": "^0.5.0", "cli-progress": "^3.10.0", + "commander": "^9.0.0", "ejs": "^3.1.5", - "glob": "^8.0.1", - "graphology": "^0.25.4", - "graphology-dag": "^0.3.0", + "glob": "^7.1.6", "joi": "^17.0.0", - "js-yaml": "^4.0.0", + "js-yaml": "3.14.1", "lodash": "^4.0.0", "opencollective-postinstall": "^2.0.3", "reflect-metadata": "^0.1.13", "resolve-from": "^5.0.0", - "typescript-collections": "^1.3.3", - "yargs": "^17.5.1" + "yargs-parser": "^20.2.4" }, "bin": { - "fixtures": "dist/cli.js", - "fixtures-ts-node-commonjs": "dist/cli-ts-node-commonjs.js", - "fixtures-ts-node-esm": "dist/cli-ts-node-esm.js" + "fixtures": "dist/cli.js" }, "peerDependencies": { "typeorm": "^0.3.0" } }, - "node_modules/typeorm-fixtures-cli/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/typeorm-fixtures-cli/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/typeorm-fixtures-cli/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "sprintf-js": "~1.0.2" } }, - "node_modules/typeorm-fixtures-cli/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "node_modules/typeorm-fixtures-cli/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "engines": { - "node": ">=10" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, "node_modules/typeorm-fixtures-cli/node_modules/reflect-metadata": { @@ -10885,12 +11120,14 @@ "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", "dev": true }, - "node_modules/typeorm/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" + "node_modules/typeorm-fixtures-cli/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" } }, "node_modules/typeorm/node_modules/buffer": { @@ -10917,11 +11154,12 @@ } }, "node_modules/typeorm/node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -10932,77 +11170,23 @@ } } }, - "node_modules/typeorm/node_modules/glob": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", - "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typeorm/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typeorm/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/typeorm/node_modules/mkdirp": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", - "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typeorm/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/typeorm/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0" }, "node_modules/typeorm/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -11019,12 +11203,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-collections": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/typescript-collections/-/typescript-collections-1.3.3.tgz", - "integrity": "sha512-7sI4e/bZijOzyURng88oOFZCISQPTHozfE2sUu5AviFYk5QV7fYGb6YiDl+vKjF/pICA354JImBImL9XJWUvdQ==", - "dev": true - }, "node_modules/ua-parser-js": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", @@ -11088,6 +11266,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -11298,6 +11477,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -11336,23 +11516,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -11392,6 +11555,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e9caa292..eb9c345c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,9 +5,14 @@ "main": "index.js", "scripts": { "start": "ts-node-dev --watch ./src ./src/index.ts", - "generate": "graphql-codegen --watch", - "migrate": "ts-node-dev src/migrate.ts", + "generate": "dotenv -e .env -- graphql-codegen --config codegen.yml --watch", + "prisma": "prisma generate --schema=src/prisma/schema.prisma && prisma db push --schema=src/prisma/schema.prisma", + "prisma:migrate": "prisma migrate dev --name init --schema=src/prisma/schema.prisma", + "prisma:update": "npx prisma format --schema=src/prisma/schema.prisma", + "db:seed": "ts-node src/prisma/scripts/seed.ts", + "db:clean": "ts-node src/prisma/scripts/clean.ts", "test": "jest --runInBand --watch", + "test:onetest": "jest --runInBand --watch -- tests/resolvers/user/register.test.ts", "test:ci": "jest --runInBand" }, "author": "Alexandre Renard", @@ -17,6 +22,7 @@ "@graphql-tools/load-files": "^7.0.0", "@graphql-tools/merge": "^9.0.1", "@parcel/watcher": "^2.4.1", + "@prisma/client": "^6.9.0", "@types/multer": "^1.4.11", "argon2": "^0.31.2", "canvas": "^2.11.2", @@ -25,13 +31,16 @@ "graphql": "^16.8.1", "graphql-scalars": "^1.22.4", "jose": "^5.2.3", + "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.14", "pg": "^8.11.3", "reflect-metadata": "^0.2.1", + "simple-icons": "^15.1.0", + "supertest": "^7.1.1", "ts-node-dev": "^2.0.0", "type-graphql": "^2.0.0-beta.3", - "typeorm": "^0.3.20", + "typeorm": "^0.3.11", "uuid": "^10.0.0", "uuidv4": "^6.2.13" }, @@ -42,12 +51,17 @@ "@types/cookies": "^0.9.0", "@types/cors": "^2.8.17", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.11.15", "@types/nodemailer": "^6.4.15", + "@types/supertest": "^6.0.3", "class-validator": "^0.14.1", + "dotenv-cli": "^8.0.0", "jest": "^29.7.0", + "jest-mock-extended": "^4.0.0-beta1", + "prisma": "^6.9.0", "ts-jest": "^29.1.2", - "typeorm-fixtures-cli": "^4.0.0", + "typeorm-fixtures-cli": "^2.0.0", "typescript": "^5.3.3" } } diff --git a/backend/src/data/bdd_20250611_193838.sql b/backend/src/data/bdd_20250611_193838.sql new file mode 100644 index 00000000..e70ce3dc --- /dev/null +++ b/backend/src/data/bdd_20250611_193838.sql @@ -0,0 +1,439 @@ +/*M!999999\- enable the sandbox mode */ +-- MariaDB dump 10.19 Distrib 10.11.11-MariaDB, for Linux (x86_64) +-- +-- Host: host.docker.internal Database: portfolio +-- ------------------------------------------------------ +-- Server version 8.0.42 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `_prisma_migrations` +-- + +DROP TABLE IF EXISTS `_prisma_migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `_prisma_migrations` ( + `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `checksum` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `finished_at` datetime(3) DEFAULT NULL, + `migration_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `logs` text COLLATE utf8mb4_unicode_ci, + `rolled_back_at` datetime(3) DEFAULT NULL, + `started_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `applied_steps_count` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `_prisma_migrations` +-- + +LOCK TABLES `_prisma_migrations` WRITE; +/*!40000 ALTER TABLE `_prisma_migrations` DISABLE KEYS */; +INSERT INTO `_prisma_migrations` VALUES +('3c58c8ac-d551-4898-98d1-aa4d53c01b5a','e041004077dd54df28c8b4277cb4782c0f87a09aa4e540f40e6a06484561ff07','2025-06-10 12:06:32.783','20250610120631_add_user_model',NULL,NULL,'2025-06-10 12:06:32.720',1), +('f747b402-4d40-44d6-94f5-6a7900ee360e','7f2e11ee6de60e0b34779f5ac5575247b781a6557f619e7082dcfdd7e8b8786e','2025-06-09 22:02:45.934','20250609220244_add_education_experience',NULL,NULL,'2025-06-09 22:02:44.992',1); +/*!40000 ALTER TABLE `_prisma_migrations` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Education` +-- + +DROP TABLE IF EXISTS `Education`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Education` ( + `id` int NOT NULL AUTO_INCREMENT, + `titleFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `titleEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `diplomaLevelEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `diplomaLevelFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `school` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `location` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `year` int NOT NULL, + `startDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `month` int NOT NULL, + `typeEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `typeFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Education` +-- + +LOCK TABLES `Education` WRITE; +/*!40000 ALTER TABLE `Education` DISABLE KEYS */; +INSERT INTO `Education` VALUES +(1,'Formation Développement web & mobile','Web & Mobile Development Training','Training','Formation','La Capsule Academy','Paris (75)',2019,'April 2019','Avril 2019','June 2019','Juin 2019',3,'Education','Éducation'), +(2,'Formation Développement web & web mobile','Web & Mobile Web Development Training','RNCP Level 5 (Associate Degree)','RNCP Level 5 (Bac +2)','Wild Code School','Paris (75)',2023,'February 2023','Février 2023','July 2023','Juillet 2023',5,'Education','Éducation'), +(3,'Alternance Concepteur Développeur d\'Applications','Apprenticeship Application Designer and Developer','RNCP Level 6 (Bachelor\'s/Master\'s degree equivalent)','RNCP Level 6 (Bac +3/4)','Wild Code School','Paris (75) (Remote)',2024,'September 2023','Septembre 2023','September 2024','Septembre 2024',12,'Education','Éducation'); +/*!40000 ALTER TABLE `Education` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Experience` +-- + +DROP TABLE IF EXISTS `Experience`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Experience` ( + `id` int NOT NULL AUTO_INCREMENT, + `jobEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `jobFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `business` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `employmentContractEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `employmentContractFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `month` int NOT NULL, + `typeEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `typeFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Experience` +-- + +LOCK TABLES `Experience` WRITE; +/*!40000 ALTER TABLE `Experience` DISABLE KEYS */; +INSERT INTO `Experience` VALUES +(1,'Teacher Assistant','Assistant pédagogique','La Capsule Academy','Internship','Stage','October 2019','Octobre 2019','December 2019','Décember 2019',3,'Experience','Expérience'), +(2,'JavaScript Developer','Développeur JavaScript','Cosy Business','Internship','Stage','January 2020','Janvier 2020','March 2020','Mars 2020',2,'Experience','Expérience'), +(3,'Teaching Assistant','Assistant d\'Enseignement','Wild Code School','Apprenticeship','Alternance','September 2023','Septembre 2023','September 2024','Septembre 2024',12,'Experience','Expérience'); +/*!40000 ALTER TABLE `Experience` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Project` +-- + +DROP TABLE IF EXISTS `Project`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Project` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `descriptionEN` text COLLATE utf8mb4_unicode_ci NOT NULL, + `descriptionFR` text COLLATE utf8mb4_unicode_ci NOT NULL, + `typeDisplay` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `github` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `contentDisplay` text COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Project` +-- + +LOCK TABLES `Project` WRITE; +/*!40000 ALTER TABLE `Project` DISABLE KEYS */; +INSERT INTO `Project` VALUES +(1,'Semantik','Semantik is an innovative SEO tool that allows you to identify the most frequently asked questions by internet users on Google. By simply entering your topic, you get a comprehensive list of these questions in one click. Use them to optimize the semantics of your content and meet SEO needs.','Semantik est un outil SEO qui vous permet d\'identifier les questions les plus posées par les internautes sur Google. En renseignant simplement votre thématique, vous obtenez en un clic une liste exhaustive de ces questions. Utilisez-les pour optimiser la sémantique de vos contenus et répondre aux besoins du SEO.','image',NULL,'Semantik.png'), +(2,'Fast News Application','Fast News is a mobile application that allows users to access a wide range of information and view it offline. We have developed a technology to capture full articles as compressed images.','Fast News est une application mobile qui permet d\'accéder à un large éventail d\'informations et de les consulter hors ligne. nous avons mis au point une technologie pour capturer les articles complets sous forme d\'images compressées.','video','https://github.com/thomassauveton/fastnews','fastNewsApplication.mp4'), +(3,'CV Hermione Granger','The creation of a small website themed around a fictional resume, highlighting the journey of the famous Hermione Granger at Hogwarts. The site creatively showcases Hermione Granger\'s academic journey, skills, and achievements in the magical world of Hogwarts.','La création d\'un petit site internet sur le thème d\'un CV fictif, mettant en lumière le parcours de la célèbre Hermione Granger à Poudlard. Le site présente de manière imaginative le parcours scolaire, les compétences et les réalisations de Hermione Granger dans l\'univers magique de Poudlard.','video','https://github.com/Alexandre78R/CV-Hermione','hermione.mp4'), +(4,'NotesApp','NotesApp is a note-taking application with full authentication. An admin area is available to manage users. Users can log in, sign up, view notes, and benefit from password recovery and reset features.','NotesApp est une application de prise de notes avec une authentification complète. Espace de administrateur pour gérer les utilisateur, Les utilisateurs peuvent se connecter, s\'inscrire, consulter les notes, tout en bénéficiant de fonctionnalités de récupération et de réinitialisation du mot de passe.','image','https://github.com/Alexandre78R/NotesApp','NotesApp.png'), +(5,'Tchat','Tchat is a real-time chat application where users can send messages to each other instantly. The frontend is developed in React, while the backend uses Express and WebSocket to handle real-time communication.','Tchat est une application de chat en temps réel où les utilisateurs peuvent s\'envoyer des messages instantanément. Le frontend est développé en React, tandis que le backend utilise Express et WebSocket pour gérer la communication en temps réel.','image','https://github.com/Alexandre78R/tchat','Tchat.png'), +(6,'GuessWhat','GuessWhat is a quiz app that allows users to customize their player, choose their favorite themes, and set the number of questions as well as the time per question. GuessWhat offers personalized and flexible entertainment for quiz enthusiasts.','GuessWhat est une application de quiz qui permet aux utilisateurs de personnaliser leur joueur, de choisir leurs thèmes préférés, et de régler le nombre de questions ainsi que le temps par question. GuessWhat offre un divertissement personnalisé et flexible pour les passionnés de quiz.','video','https://github.com/Alexandre78R/Guess','guessWhatApp.mp4'), +(7,'Wonder Match','Wonder Match is an app for intrepid travelers, helping you choose your destination in a few simple steps: select desired continents, scroll through suggestions, then decide: Match or Pass. Explore activities, tourist sites, and selfie spots for perfectly planned vacations.','Wonder Match est une application pour les voyageurs intrépides, vous aide à choisir votre destination en quelques étapes simples : sélectionnez les continents désirés, faites défiler les suggestions, puis décidez : Match ou Pass. Explorez les activités, sites touristiques et spots pour selfies, pour des vacances parfaitement planifiées.','video','https://github.com/Alexandre78R/WonderMatch','wonderMatch.mp4'), +(8,'Makesense intranet','Makesense, founded in 2010, encourages sustainability and engagement through ecological and social projects. An intranet platform is needed to create, evaluate, and vote on projects. Administrators can manage users, posts, and roles, with decisions being made through a voting system based on the user\'s role.','Makesense, fondée en 2010, encourage la durabilité et l\'engagement à travers des projets écologiques et sociaux. Une plateforme intranet est nécessaire pour créer, évaluer et voter sur les projets. Les administrateurs peuvent gérer les utilisateurs, les publications et les rôles, les décisions étant prises par un système de vote basé sur le rôle de l\'utilisateur.','video','https://github.com/Alexandre78R/makesense-client','makesense.mp4'), +(9,'WildCodeHub','WildCodeHub is an online code development platform. Users can create, test, and share their code, with an intuitive interface and backup features. Social interactions are planned, and future developments will include support for new languages and real-time collaboration.','WildCodeHub est une plateforme de développement de code en ligne. Les utilisateurs peuvent créer, tester et partager leur code, avec une interface intuitive et des fonctionnalités de sauvegarde. Des interactions sociales sont prévues, et des évolutions incluront le support de nouveaux langages et la collaboration en temps réel.','video','https://github.com/WildCodeSchool/2309-wns-jaune-wild-code-hub','wildCodeHub.mp4'), +(10,'Portfolio','My Portfolio – A personal project built to showcase my background, skills, and projects. Developed with React, Next.js, and TypeScript on the frontend, and Express, GraphQL, and Prisma on the backend. Clean design with Tailwind CSS, for a site that reflects who I am: simple, clear, and efficient.','Mon Portfolio – Un projet personnel qui me permet de présenter mon parcours, mes compétences et mes projets. Conçu avec React, Next.js et TypeScript côté frontend, et un backend en Express, GraphQL et Prisma. Une interface soignée avec Tailwind, pour un site à mon image : simple, clair et efficace.','image','https://github.com/Alexandre78R/portfolio','Portfolio.png'), +(11,'DailyLog','DailyLog is a personal journaling application that allows users to record their daily moods and view related statistics. The main goal of this project is to practice and deepen Angular skills, while building a simple backend using Express, Prisma, and MySQL to manage the data.','DailyLog est une application de journal de bord personnel permettant d’enregistrer ses humeurs quotidiennes et de visualiser des statistiques associées. Le but principal de ce projet est de pratiquer et approfondir les compétences en Angular, tout en développant un backend simple avec Express, Prisma et MySQL pour gérer les données.','video','https://github.com/Alexandre78R/Project-DailyLog-Angular','dailyLog.mp4'); +/*!40000 ALTER TABLE `Project` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `ProjectSkill` +-- + +DROP TABLE IF EXISTS `ProjectSkill`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `ProjectSkill` ( + `projectId` int NOT NULL, + `skillId` int NOT NULL, + PRIMARY KEY (`projectId`,`skillId`), + KEY `ProjectSkill_skillId_fkey` (`skillId`), + CONSTRAINT `ProjectSkill_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ProjectSkill_skillId_fkey` FOREIGN KEY (`skillId`) REFERENCES `Skill` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `ProjectSkill` +-- + +LOCK TABLES `ProjectSkill` WRITE; +/*!40000 ALTER TABLE `ProjectSkill` DISABLE KEYS */; +INSERT INTO `ProjectSkill` VALUES +(1,1), +(2,1), +(3,1), +(4,1), +(5,1), +(6,1), +(7,1), +(8,1), +(9,2), +(10,2), +(11,2), +(1,3), +(4,3), +(5,3), +(6,3), +(8,3), +(9,3), +(10,3), +(11,3), +(9,4), +(10,4), +(9,6), +(10,6), +(1,7), +(4,7), +(5,7), +(6,7), +(8,7), +(10,7), +(11,7), +(9,8), +(8,9), +(10,9), +(11,9), +(10,12), +(11,12), +(9,14), +(10,14), +(9,15), +(10,15), +(9,16), +(10,16), +(9,17), +(10,17), +(4,19), +(5,19), +(6,19), +(7,19), +(8,19), +(9,20), +(10,20), +(2,21), +(4,21), +(8,21), +(10,21), +(11,22), +(11,23), +(10,24), +(11,24), +(9,26), +(1,27), +(2,27), +(5,28), +(7,28), +(8,28), +(9,29), +(10,29), +(11,30), +(1,32), +(2,32), +(2,33), +(2,34), +(4,36), +(6,36), +(7,36), +(8,36), +(9,36), +(10,36), +(4,37), +(6,37), +(7,37), +(8,37), +(9,37), +(10,37), +(11,37), +(4,38), +(5,38), +(6,38), +(7,38), +(8,38), +(9,38), +(10,38), +(11,38), +(1,39), +(2,39), +(3,39), +(6,39), +(1,40), +(2,40), +(3,40), +(4,40), +(6,40); +/*!40000 ALTER TABLE `ProjectSkill` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Skill` +-- + +DROP TABLE IF EXISTS `Skill`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Skill` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `image` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `categoryId` int NOT NULL, + PRIMARY KEY (`id`), + KEY `Skill_categoryId_fkey` (`categoryId`), + CONSTRAINT `Skill_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `SkillCategory` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Skill` +-- + +LOCK TABLES `Skill` WRITE; +/*!40000 ALTER TABLE `Skill` DISABLE KEYS */; +INSERT INTO `Skill` VALUES +(1,'JavaScript','https://img.shields.io/badge/-JavaScript-efd81d?style=flat-square&logo=JavaScript&logoColor=white',1), +(2,'TypeScript','https://img.shields.io/badge/-TypeScript-007ACC?style=flat-square&logo=typescript&logoColor=white',1), +(3,'Nodejs','https://img.shields.io/badge/-Nodejs-44883e?style=flat-square&logo=Node.js&logoColor=white',2), +(4,'Apollo GraphQL','https://img.shields.io/badge/-Apollo%20GraphQL-311C87?style=flat-square&logo=apollo-graphql&logoColor=white',2), +(5,'GraphQL','https://img.shields.io/badge/-GraphQL-E535AB?style=flat-square&logo=graphql&logoColor=white',2), +(6,'TypeGraphQL','https://img.shields.io/badge/-TypeGraphQL-5149B8?style=flat-square&logo=graphql&logoColor=white',2), +(7,'Express','https://img.shields.io/badge/-Express-000000?style=flat-square&logoColor=white',2), +(8,'PostgreSQL','https://img.shields.io/badge/-PostgreSQL-1D73DC?style=flat-square&logo=PostgreSQL&logoColor=white',3), +(9,'MySQL','https://img.shields.io/badge/-MySQL-F29111?style=flat-square&logo=MySQL&logoColor=white',3), +(10,'SQLite','https://img.shields.io/badge/-SQLite-1E8DBC?style=flat-square&logo=SQLite&logoColor=white',3), +(11,'MongoDB','https://img.shields.io/badge/-MongoDB-1DBA22?style=flat-square&logo=mongodb&logoColor=white',3), +(12,'Prisma','https://img.shields.io/badge/-Prisma-000000?style=flat-square&logo=Prisma&logoColor=white',3), +(13,'Knex.js','https://img.shields.io/badge/-Knex.js-E95602?style=flat-square&logo=Knex.js&logoColor=white',3), +(14,'Docker','https://img.shields.io/badge/-Docker-0db7ed?style=flat-square&logo=docker&logoColor=white',4), +(15,'Github Action','https://img.shields.io/badge/-Github%20Action-000000?style=flat-square&logo=github$&logoColor=white',4), +(16,'Caddy','https://img.shields.io/badge/-Caddy-26CFA7?style=flat-square&logo=caddy&logoColor=white',4), +(17,'Nginx','https://img.shields.io/badge/-Nginx-1EA718?style=flat-square&logo=nginx&logoColor=white',4), +(18,'Heroku','https://img.shields.io/badge/-Heroku-7B0FF5?style=flat-square&logo=heroku&logoColor=white',4), +(19,'React','https://img.shields.io/badge/-React-45b8d8?style=flat-square&logo=react&logoColor=white',5), +(20,'Next.js','https://img.shields.io/badge/-Next.js-000000?style=flat-square&logo=next.js&logoColor=white',5), +(21,'Redux','https://img.shields.io/badge/-Redux-8C1EB2?style=flat-square&logo=redux&logoColor=white',5), +(22,'Angular','https://img.shields.io/badge/-Angular-DD0031?style=flat-square&logo=angular&logoColor=white',5), +(23,'RxJS','https://img.shields.io/badge/-RxJS-B7178C?style=flat-square&logo=reactivex&logoColor=white',5), +(24,'Tailwind CSS','https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white',6), +(25,'MUI','https://img.shields.io/badge/-MUI-167FDC?style=flat-square&logo=mui&logoColor=white',6), +(26,'Chakra UI','https://img.shields.io/badge/-Chakra%20UI-36C5CA?style=flat-square&logo=chakra-ui&logoColor=white',6), +(27,'Bootstrap','https://img.shields.io/badge/-Bootstrap-a259ff?style=flat-square&logo=bootstrap&logoColor=white',6), +(28,'SASS','https://img.shields.io/badge/-SASS-CC69BF?style=flat-square&logo=sass&logoColor=white',6), +(29,'Jest','https://img.shields.io/badge/-Jest-FC958A?style=flat-square&logo=jest&logoColor=white',7), +(30,'Karma','https://img.shields.io/badge/-Karma-3A3A3A?style=flat-square&logo=karma&logoColor=white',7), +(31,'Cypress','https://img.shields.io/badge/-Cypress-1FC824?style=flat-square&logo=cypress&logoColor=white',7), +(32,'Puppeteer','https://img.shields.io/badge/-Puppeteer-1DB356?style=flat-square&logo=puppeteer&logoColor=white',7), +(33,'React Native','https://img.shields.io/badge/-React%20Native-45b8d8?style=flat-square&logo=react&logoColor=white',8), +(34,'Expo','https://img.shields.io/badge/Expo-000000?style=flat-square&logo=expo&logoColor=white',8), +(35,'NativeWind','https://img.shields.io/badge/NativeWind-45b8d8?style=flat-square&logo=react&logoColor=white',8), +(36,'Figma','https://img.shields.io/badge/-Figma-a259ff?style=flat-square&logo=Figma&logoColor=white',9), +(37,'Postman','https://img.shields.io/badge/-Postman-F66526?style=flat-square&logo=Postman&logoColor=white',9), +(38,'Git','https://img.shields.io/badge/-Git-F14E32?style=flat-square&logo=git&logoColor=white',9), +(39,'HTML5','https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white',10), +(40,'CSS3','https://img.shields.io/badge/-CSS3-264de4?style=flat-square&logo=css3&logoColor=white',10); +/*!40000 ALTER TABLE `Skill` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `SkillCategory` +-- + +DROP TABLE IF EXISTS `SkillCategory`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `SkillCategory` ( + `id` int NOT NULL AUTO_INCREMENT, + `categoryEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `categoryFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `SkillCategory` +-- + +LOCK TABLES `SkillCategory` WRITE; +/*!40000 ALTER TABLE `SkillCategory` DISABLE KEYS */; +INSERT INTO `SkillCategory` VALUES +(1,'Programming Languages','Langages de Programmation'), +(2,'Backend Development','Développement Backend'), +(3,'Database - Storage & Query','Base de données - Stockage et Requête'), +(4,'DevOps','DevOps'), +(5,'Frontend Frameworks & Libraries','Frameworks & Bibliothèques Frontend'), +(6,'UI Frameworks & Styling Tools','Frameworks UI & Outils de Style'), +(7,'Testing & Web Scraping','Tests & Scraping Web'), +(8,'Mobile App Development','Développement d\'Applications Mobiles'), +(9,'Tools','Outils'), +(10,'Markup & Styling Languages','Langages de Marquage & de Style'); +/*!40000 ALTER TABLE `SkillCategory` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `User` +-- + +DROP TABLE IF EXISTS `User`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `User` ( + `id` int NOT NULL AUTO_INCREMENT, + `firstname` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastname` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `role` enum('admin','editor','view') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'view', + `isPasswordChange` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `User_email_key` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `User` +-- + +LOCK TABLES `User` WRITE; +/*!40000 ALTER TABLE `User` DISABLE KEYS */; +/*!40000 ALTER TABLE `User` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-06-11 19:38:38 diff --git a/backend/src/data/bdd_20250611_193843.sql b/backend/src/data/bdd_20250611_193843.sql new file mode 100644 index 00000000..98674c98 --- /dev/null +++ b/backend/src/data/bdd_20250611_193843.sql @@ -0,0 +1,439 @@ +/*M!999999\- enable the sandbox mode */ +-- MariaDB dump 10.19 Distrib 10.11.11-MariaDB, for Linux (x86_64) +-- +-- Host: host.docker.internal Database: portfolio +-- ------------------------------------------------------ +-- Server version 8.0.42 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `_prisma_migrations` +-- + +DROP TABLE IF EXISTS `_prisma_migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `_prisma_migrations` ( + `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `checksum` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `finished_at` datetime(3) DEFAULT NULL, + `migration_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `logs` text COLLATE utf8mb4_unicode_ci, + `rolled_back_at` datetime(3) DEFAULT NULL, + `started_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `applied_steps_count` int unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `_prisma_migrations` +-- + +LOCK TABLES `_prisma_migrations` WRITE; +/*!40000 ALTER TABLE `_prisma_migrations` DISABLE KEYS */; +INSERT INTO `_prisma_migrations` VALUES +('3c58c8ac-d551-4898-98d1-aa4d53c01b5a','e041004077dd54df28c8b4277cb4782c0f87a09aa4e540f40e6a06484561ff07','2025-06-10 12:06:32.783','20250610120631_add_user_model',NULL,NULL,'2025-06-10 12:06:32.720',1), +('f747b402-4d40-44d6-94f5-6a7900ee360e','7f2e11ee6de60e0b34779f5ac5575247b781a6557f619e7082dcfdd7e8b8786e','2025-06-09 22:02:45.934','20250609220244_add_education_experience',NULL,NULL,'2025-06-09 22:02:44.992',1); +/*!40000 ALTER TABLE `_prisma_migrations` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Education` +-- + +DROP TABLE IF EXISTS `Education`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Education` ( + `id` int NOT NULL AUTO_INCREMENT, + `titleFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `titleEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `diplomaLevelEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `diplomaLevelFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `school` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `location` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `year` int NOT NULL, + `startDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `month` int NOT NULL, + `typeEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `typeFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Education` +-- + +LOCK TABLES `Education` WRITE; +/*!40000 ALTER TABLE `Education` DISABLE KEYS */; +INSERT INTO `Education` VALUES +(1,'Formation Développement web & mobile','Web & Mobile Development Training','Training','Formation','La Capsule Academy','Paris (75)',2019,'April 2019','Avril 2019','June 2019','Juin 2019',3,'Education','Éducation'), +(2,'Formation Développement web & web mobile','Web & Mobile Web Development Training','RNCP Level 5 (Associate Degree)','RNCP Level 5 (Bac +2)','Wild Code School','Paris (75)',2023,'February 2023','Février 2023','July 2023','Juillet 2023',5,'Education','Éducation'), +(3,'Alternance Concepteur Développeur d\'Applications','Apprenticeship Application Designer and Developer','RNCP Level 6 (Bachelor\'s/Master\'s degree equivalent)','RNCP Level 6 (Bac +3/4)','Wild Code School','Paris (75) (Remote)',2024,'September 2023','Septembre 2023','September 2024','Septembre 2024',12,'Education','Éducation'); +/*!40000 ALTER TABLE `Education` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Experience` +-- + +DROP TABLE IF EXISTS `Experience`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Experience` ( + `id` int NOT NULL AUTO_INCREMENT, + `jobEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `jobFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `business` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `employmentContractEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `employmentContractFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `startDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `endDateFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `month` int NOT NULL, + `typeEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `typeFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Experience` +-- + +LOCK TABLES `Experience` WRITE; +/*!40000 ALTER TABLE `Experience` DISABLE KEYS */; +INSERT INTO `Experience` VALUES +(1,'Teacher Assistant','Assistant pédagogique','La Capsule Academy','Internship','Stage','October 2019','Octobre 2019','December 2019','Décember 2019',3,'Experience','Expérience'), +(2,'JavaScript Developer','Développeur JavaScript','Cosy Business','Internship','Stage','January 2020','Janvier 2020','March 2020','Mars 2020',2,'Experience','Expérience'), +(3,'Teaching Assistant','Assistant d\'Enseignement','Wild Code School','Apprenticeship','Alternance','September 2023','Septembre 2023','September 2024','Septembre 2024',12,'Experience','Expérience'); +/*!40000 ALTER TABLE `Experience` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Project` +-- + +DROP TABLE IF EXISTS `Project`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Project` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `descriptionEN` text COLLATE utf8mb4_unicode_ci NOT NULL, + `descriptionFR` text COLLATE utf8mb4_unicode_ci NOT NULL, + `typeDisplay` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `github` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `contentDisplay` text COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Project` +-- + +LOCK TABLES `Project` WRITE; +/*!40000 ALTER TABLE `Project` DISABLE KEYS */; +INSERT INTO `Project` VALUES +(1,'Semantik','Semantik is an innovative SEO tool that allows you to identify the most frequently asked questions by internet users on Google. By simply entering your topic, you get a comprehensive list of these questions in one click. Use them to optimize the semantics of your content and meet SEO needs.','Semantik est un outil SEO qui vous permet d\'identifier les questions les plus posées par les internautes sur Google. En renseignant simplement votre thématique, vous obtenez en un clic une liste exhaustive de ces questions. Utilisez-les pour optimiser la sémantique de vos contenus et répondre aux besoins du SEO.','image',NULL,'Semantik.png'), +(2,'Fast News Application','Fast News is a mobile application that allows users to access a wide range of information and view it offline. We have developed a technology to capture full articles as compressed images.','Fast News est une application mobile qui permet d\'accéder à un large éventail d\'informations et de les consulter hors ligne. nous avons mis au point une technologie pour capturer les articles complets sous forme d\'images compressées.','video','https://github.com/thomassauveton/fastnews','fastNewsApplication.mp4'), +(3,'CV Hermione Granger','The creation of a small website themed around a fictional resume, highlighting the journey of the famous Hermione Granger at Hogwarts. The site creatively showcases Hermione Granger\'s academic journey, skills, and achievements in the magical world of Hogwarts.','La création d\'un petit site internet sur le thème d\'un CV fictif, mettant en lumière le parcours de la célèbre Hermione Granger à Poudlard. Le site présente de manière imaginative le parcours scolaire, les compétences et les réalisations de Hermione Granger dans l\'univers magique de Poudlard.','video','https://github.com/Alexandre78R/CV-Hermione','hermione.mp4'), +(4,'NotesApp','NotesApp is a note-taking application with full authentication. An admin area is available to manage users. Users can log in, sign up, view notes, and benefit from password recovery and reset features.','NotesApp est une application de prise de notes avec une authentification complète. Espace de administrateur pour gérer les utilisateur, Les utilisateurs peuvent se connecter, s\'inscrire, consulter les notes, tout en bénéficiant de fonctionnalités de récupération et de réinitialisation du mot de passe.','image','https://github.com/Alexandre78R/NotesApp','NotesApp.png'), +(5,'Tchat','Tchat is a real-time chat application where users can send messages to each other instantly. The frontend is developed in React, while the backend uses Express and WebSocket to handle real-time communication.','Tchat est une application de chat en temps réel où les utilisateurs peuvent s\'envoyer des messages instantanément. Le frontend est développé en React, tandis que le backend utilise Express et WebSocket pour gérer la communication en temps réel.','image','https://github.com/Alexandre78R/tchat','Tchat.png'), +(6,'GuessWhat','GuessWhat is a quiz app that allows users to customize their player, choose their favorite themes, and set the number of questions as well as the time per question. GuessWhat offers personalized and flexible entertainment for quiz enthusiasts.','GuessWhat est une application de quiz qui permet aux utilisateurs de personnaliser leur joueur, de choisir leurs thèmes préférés, et de régler le nombre de questions ainsi que le temps par question. GuessWhat offre un divertissement personnalisé et flexible pour les passionnés de quiz.','video','https://github.com/Alexandre78R/Guess','guessWhatApp.mp4'), +(7,'Wonder Match','Wonder Match is an app for intrepid travelers, helping you choose your destination in a few simple steps: select desired continents, scroll through suggestions, then decide: Match or Pass. Explore activities, tourist sites, and selfie spots for perfectly planned vacations.','Wonder Match est une application pour les voyageurs intrépides, vous aide à choisir votre destination en quelques étapes simples : sélectionnez les continents désirés, faites défiler les suggestions, puis décidez : Match ou Pass. Explorez les activités, sites touristiques et spots pour selfies, pour des vacances parfaitement planifiées.','video','https://github.com/Alexandre78R/WonderMatch','wonderMatch.mp4'), +(8,'Makesense intranet','Makesense, founded in 2010, encourages sustainability and engagement through ecological and social projects. An intranet platform is needed to create, evaluate, and vote on projects. Administrators can manage users, posts, and roles, with decisions being made through a voting system based on the user\'s role.','Makesense, fondée en 2010, encourage la durabilité et l\'engagement à travers des projets écologiques et sociaux. Une plateforme intranet est nécessaire pour créer, évaluer et voter sur les projets. Les administrateurs peuvent gérer les utilisateurs, les publications et les rôles, les décisions étant prises par un système de vote basé sur le rôle de l\'utilisateur.','video','https://github.com/Alexandre78R/makesense-client','makesense.mp4'), +(9,'WildCodeHub','WildCodeHub is an online code development platform. Users can create, test, and share their code, with an intuitive interface and backup features. Social interactions are planned, and future developments will include support for new languages and real-time collaboration.','WildCodeHub est une plateforme de développement de code en ligne. Les utilisateurs peuvent créer, tester et partager leur code, avec une interface intuitive et des fonctionnalités de sauvegarde. Des interactions sociales sont prévues, et des évolutions incluront le support de nouveaux langages et la collaboration en temps réel.','video','https://github.com/WildCodeSchool/2309-wns-jaune-wild-code-hub','wildCodeHub.mp4'), +(10,'Portfolio','My Portfolio – A personal project built to showcase my background, skills, and projects. Developed with React, Next.js, and TypeScript on the frontend, and Express, GraphQL, and Prisma on the backend. Clean design with Tailwind CSS, for a site that reflects who I am: simple, clear, and efficient.','Mon Portfolio – Un projet personnel qui me permet de présenter mon parcours, mes compétences et mes projets. Conçu avec React, Next.js et TypeScript côté frontend, et un backend en Express, GraphQL et Prisma. Une interface soignée avec Tailwind, pour un site à mon image : simple, clair et efficace.','image','https://github.com/Alexandre78R/portfolio','Portfolio.png'), +(11,'DailyLog','DailyLog is a personal journaling application that allows users to record their daily moods and view related statistics. The main goal of this project is to practice and deepen Angular skills, while building a simple backend using Express, Prisma, and MySQL to manage the data.','DailyLog est une application de journal de bord personnel permettant d’enregistrer ses humeurs quotidiennes et de visualiser des statistiques associées. Le but principal de ce projet est de pratiquer et approfondir les compétences en Angular, tout en développant un backend simple avec Express, Prisma et MySQL pour gérer les données.','video','https://github.com/Alexandre78R/Project-DailyLog-Angular','dailyLog.mp4'); +/*!40000 ALTER TABLE `Project` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `ProjectSkill` +-- + +DROP TABLE IF EXISTS `ProjectSkill`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `ProjectSkill` ( + `projectId` int NOT NULL, + `skillId` int NOT NULL, + PRIMARY KEY (`projectId`,`skillId`), + KEY `ProjectSkill_skillId_fkey` (`skillId`), + CONSTRAINT `ProjectSkill_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT `ProjectSkill_skillId_fkey` FOREIGN KEY (`skillId`) REFERENCES `Skill` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `ProjectSkill` +-- + +LOCK TABLES `ProjectSkill` WRITE; +/*!40000 ALTER TABLE `ProjectSkill` DISABLE KEYS */; +INSERT INTO `ProjectSkill` VALUES +(1,1), +(2,1), +(3,1), +(4,1), +(5,1), +(6,1), +(7,1), +(8,1), +(9,2), +(10,2), +(11,2), +(1,3), +(4,3), +(5,3), +(6,3), +(8,3), +(9,3), +(10,3), +(11,3), +(9,4), +(10,4), +(9,6), +(10,6), +(1,7), +(4,7), +(5,7), +(6,7), +(8,7), +(10,7), +(11,7), +(9,8), +(8,9), +(10,9), +(11,9), +(10,12), +(11,12), +(9,14), +(10,14), +(9,15), +(10,15), +(9,16), +(10,16), +(9,17), +(10,17), +(4,19), +(5,19), +(6,19), +(7,19), +(8,19), +(9,20), +(10,20), +(2,21), +(4,21), +(8,21), +(10,21), +(11,22), +(11,23), +(10,24), +(11,24), +(9,26), +(1,27), +(2,27), +(5,28), +(7,28), +(8,28), +(9,29), +(10,29), +(11,30), +(1,32), +(2,32), +(2,33), +(2,34), +(4,36), +(6,36), +(7,36), +(8,36), +(9,36), +(10,36), +(4,37), +(6,37), +(7,37), +(8,37), +(9,37), +(10,37), +(11,37), +(4,38), +(5,38), +(6,38), +(7,38), +(8,38), +(9,38), +(10,38), +(11,38), +(1,39), +(2,39), +(3,39), +(6,39), +(1,40), +(2,40), +(3,40), +(4,40), +(6,40); +/*!40000 ALTER TABLE `ProjectSkill` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `Skill` +-- + +DROP TABLE IF EXISTS `Skill`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `Skill` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `image` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `categoryId` int NOT NULL, + PRIMARY KEY (`id`), + KEY `Skill_categoryId_fkey` (`categoryId`), + CONSTRAINT `Skill_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `SkillCategory` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `Skill` +-- + +LOCK TABLES `Skill` WRITE; +/*!40000 ALTER TABLE `Skill` DISABLE KEYS */; +INSERT INTO `Skill` VALUES +(1,'JavaScript','https://img.shields.io/badge/-JavaScript-efd81d?style=flat-square&logo=JavaScript&logoColor=white',1), +(2,'TypeScript','https://img.shields.io/badge/-TypeScript-007ACC?style=flat-square&logo=typescript&logoColor=white',1), +(3,'Nodejs','https://img.shields.io/badge/-Nodejs-44883e?style=flat-square&logo=Node.js&logoColor=white',2), +(4,'Apollo GraphQL','https://img.shields.io/badge/-Apollo%20GraphQL-311C87?style=flat-square&logo=apollo-graphql&logoColor=white',2), +(5,'GraphQL','https://img.shields.io/badge/-GraphQL-E535AB?style=flat-square&logo=graphql&logoColor=white',2), +(6,'TypeGraphQL','https://img.shields.io/badge/-TypeGraphQL-5149B8?style=flat-square&logo=graphql&logoColor=white',2), +(7,'Express','https://img.shields.io/badge/-Express-000000?style=flat-square&logoColor=white',2), +(8,'PostgreSQL','https://img.shields.io/badge/-PostgreSQL-1D73DC?style=flat-square&logo=PostgreSQL&logoColor=white',3), +(9,'MySQL','https://img.shields.io/badge/-MySQL-F29111?style=flat-square&logo=MySQL&logoColor=white',3), +(10,'SQLite','https://img.shields.io/badge/-SQLite-1E8DBC?style=flat-square&logo=SQLite&logoColor=white',3), +(11,'MongoDB','https://img.shields.io/badge/-MongoDB-1DBA22?style=flat-square&logo=mongodb&logoColor=white',3), +(12,'Prisma','https://img.shields.io/badge/-Prisma-000000?style=flat-square&logo=Prisma&logoColor=white',3), +(13,'Knex.js','https://img.shields.io/badge/-Knex.js-E95602?style=flat-square&logo=Knex.js&logoColor=white',3), +(14,'Docker','https://img.shields.io/badge/-Docker-0db7ed?style=flat-square&logo=docker&logoColor=white',4), +(15,'Github Action','https://img.shields.io/badge/-Github%20Action-000000?style=flat-square&logo=github$&logoColor=white',4), +(16,'Caddy','https://img.shields.io/badge/-Caddy-26CFA7?style=flat-square&logo=caddy&logoColor=white',4), +(17,'Nginx','https://img.shields.io/badge/-Nginx-1EA718?style=flat-square&logo=nginx&logoColor=white',4), +(18,'Heroku','https://img.shields.io/badge/-Heroku-7B0FF5?style=flat-square&logo=heroku&logoColor=white',4), +(19,'React','https://img.shields.io/badge/-React-45b8d8?style=flat-square&logo=react&logoColor=white',5), +(20,'Next.js','https://img.shields.io/badge/-Next.js-000000?style=flat-square&logo=next.js&logoColor=white',5), +(21,'Redux','https://img.shields.io/badge/-Redux-8C1EB2?style=flat-square&logo=redux&logoColor=white',5), +(22,'Angular','https://img.shields.io/badge/-Angular-DD0031?style=flat-square&logo=angular&logoColor=white',5), +(23,'RxJS','https://img.shields.io/badge/-RxJS-B7178C?style=flat-square&logo=reactivex&logoColor=white',5), +(24,'Tailwind CSS','https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white',6), +(25,'MUI','https://img.shields.io/badge/-MUI-167FDC?style=flat-square&logo=mui&logoColor=white',6), +(26,'Chakra UI','https://img.shields.io/badge/-Chakra%20UI-36C5CA?style=flat-square&logo=chakra-ui&logoColor=white',6), +(27,'Bootstrap','https://img.shields.io/badge/-Bootstrap-a259ff?style=flat-square&logo=bootstrap&logoColor=white',6), +(28,'SASS','https://img.shields.io/badge/-SASS-CC69BF?style=flat-square&logo=sass&logoColor=white',6), +(29,'Jest','https://img.shields.io/badge/-Jest-FC958A?style=flat-square&logo=jest&logoColor=white',7), +(30,'Karma','https://img.shields.io/badge/-Karma-3A3A3A?style=flat-square&logo=karma&logoColor=white',7), +(31,'Cypress','https://img.shields.io/badge/-Cypress-1FC824?style=flat-square&logo=cypress&logoColor=white',7), +(32,'Puppeteer','https://img.shields.io/badge/-Puppeteer-1DB356?style=flat-square&logo=puppeteer&logoColor=white',7), +(33,'React Native','https://img.shields.io/badge/-React%20Native-45b8d8?style=flat-square&logo=react&logoColor=white',8), +(34,'Expo','https://img.shields.io/badge/Expo-000000?style=flat-square&logo=expo&logoColor=white',8), +(35,'NativeWind','https://img.shields.io/badge/NativeWind-45b8d8?style=flat-square&logo=react&logoColor=white',8), +(36,'Figma','https://img.shields.io/badge/-Figma-a259ff?style=flat-square&logo=Figma&logoColor=white',9), +(37,'Postman','https://img.shields.io/badge/-Postman-F66526?style=flat-square&logo=Postman&logoColor=white',9), +(38,'Git','https://img.shields.io/badge/-Git-F14E32?style=flat-square&logo=git&logoColor=white',9), +(39,'HTML5','https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white',10), +(40,'CSS3','https://img.shields.io/badge/-CSS3-264de4?style=flat-square&logo=css3&logoColor=white',10); +/*!40000 ALTER TABLE `Skill` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `SkillCategory` +-- + +DROP TABLE IF EXISTS `SkillCategory`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `SkillCategory` ( + `id` int NOT NULL AUTO_INCREMENT, + `categoryEN` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `categoryFR` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `SkillCategory` +-- + +LOCK TABLES `SkillCategory` WRITE; +/*!40000 ALTER TABLE `SkillCategory` DISABLE KEYS */; +INSERT INTO `SkillCategory` VALUES +(1,'Programming Languages','Langages de Programmation'), +(2,'Backend Development','Développement Backend'), +(3,'Database - Storage & Query','Base de données - Stockage et Requête'), +(4,'DevOps','DevOps'), +(5,'Frontend Frameworks & Libraries','Frameworks & Bibliothèques Frontend'), +(6,'UI Frameworks & Styling Tools','Frameworks UI & Outils de Style'), +(7,'Testing & Web Scraping','Tests & Scraping Web'), +(8,'Mobile App Development','Développement d\'Applications Mobiles'), +(9,'Tools','Outils'), +(10,'Markup & Styling Languages','Langages de Marquage & de Style'); +/*!40000 ALTER TABLE `SkillCategory` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `User` +-- + +DROP TABLE IF EXISTS `User`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `User` ( + `id` int NOT NULL AUTO_INCREMENT, + `firstname` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastname` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + `role` enum('admin','editor','view') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'view', + `isPasswordChange` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `User_email_key` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `User` +-- + +LOCK TABLES `User` WRITE; +/*!40000 ALTER TABLE `User` DISABLE KEYS */; +/*!40000 ALTER TABLE `User` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-06-11 19:38:43 diff --git a/backend/src/entities/education.entity.ts b/backend/src/entities/education.entity.ts new file mode 100644 index 00000000..dfcad333 --- /dev/null +++ b/backend/src/entities/education.entity.ts @@ -0,0 +1,49 @@ +import { ObjectType, Field, ID, Int } from "type-graphql"; + +@ObjectType() +export class Education { + @Field(() => ID) + id: number; + + @Field() + titleFR: string; + + @Field() + titleEN: string; + + @Field() + diplomaLevelFR: string; + + @Field() + diplomaLevelEN: string; + + @Field() + school: string; + + @Field() + location: string; + + @Field(() => Int) + year: number; + + @Field() + startDateEN: string; + + @Field() + startDateFR: string; + + @Field() + endDateEN: string; + + @Field() + endDateFR: string; + + @Field(() => Int, { nullable: true }) + month?: number; + + @Field() + typeEN: string; + + @Field() + typeFR: string; +} \ No newline at end of file diff --git a/backend/src/entities/experience.entity.ts b/backend/src/entities/experience.entity.ts new file mode 100644 index 00000000..6d1e573c --- /dev/null +++ b/backend/src/entities/experience.entity.ts @@ -0,0 +1,43 @@ +import { ObjectType, Field, ID, Int } from "type-graphql"; + +@ObjectType() +export class Experience { + @Field(() => ID) + id: number; + + @Field() + jobEN: string; + + @Field() + jobFR: string; + + @Field() + business: string; + + @Field() + employmentContractEN: string; + + @Field() + employmentContractFR: string; + + @Field() + startDateEN: string; + + @Field() + startDateFR: string; + + @Field() + endDateEN: string; + + @Field() + endDateFR: string; + + @Field() + month: number; + + @Field() + typeEN: string; + + @Field() + typeFR: string; +} \ No newline at end of file diff --git a/backend/src/entities/inputs/education.input.ts b/backend/src/entities/inputs/education.input.ts new file mode 100644 index 00000000..d0fc8688 --- /dev/null +++ b/backend/src/entities/inputs/education.input.ts @@ -0,0 +1,94 @@ +import { InputType, Field, Int } from "type-graphql"; + +@InputType() +export class CreateEducationInput { + @Field() + titleFR: string; + + @Field() + titleEN: string; + + @Field() + diplomaLevelEN: string; + + @Field() + diplomaLevelFR: string; + + @Field() + school: string; + + @Field() + location: string; + + @Field(() => Int) + year: number; + + @Field() + startDateEN: string; + + @Field() + startDateFR: string; + + @Field() + endDateEN: string; + + @Field() + endDateFR: string; + + @Field(() => Int) + month: number; + + @Field() + typeEN: string; + + @Field() + typeFR: string; +} + +@InputType() +export class UpdateEducationInput { + @Field(() => Int) + id: number; + + @Field({ nullable: true }) + titleFR?: string; + + @Field({ nullable: true }) + titleEN?: string; + + @Field({ nullable: true }) + diplomaLevelEN?: string; + + @Field({ nullable: true }) + diplomaLevelFR?: string; + + @Field({ nullable: true }) + school?: string; + + @Field({ nullable: true }) + location?: string; + + @Field(() => Int, { nullable: true }) + year?: number; + + @Field({ nullable: true }) + startDateEN?: string; + + @Field({ nullable: true }) + startDateFR?: string; + + @Field({ nullable: true }) + endDateEN?: string; + + @Field({ nullable: true }) + endDateFR?: string; + + @Field(() => Int, { nullable: true }) + month?: number; + + @Field({ nullable: true }) + typeEN?: string; + + @Field({ nullable: true }) + typeFR?: string; +} \ No newline at end of file diff --git a/backend/src/entities/inputs/experience.input.ts b/backend/src/entities/inputs/experience.input.ts new file mode 100644 index 00000000..8bfad760 --- /dev/null +++ b/backend/src/entities/inputs/experience.input.ts @@ -0,0 +1,85 @@ +import { InputType, Field, Int } from "type-graphql"; + +@InputType() +export class CreateExperienceInput { + + @Field() + jobEN: string; + + @Field() + jobFR: string; + + @Field() + business: string; + + @Field() + employmentContractEN: string; + + @Field() + employmentContractFR: string; + + @Field() + startDateEN: string; + + @Field() + startDateFR: string; + + @Field() + endDateEN: string; + + @Field() + endDateFR: string; + + @Field() + month: number; + + @Field() + typeEN: string; + + @Field() + typeFR: string; + +} + +@InputType() +export class UpdateExperienceInput { + + @Field(() => Int) + id: number; + + @Field({ nullable: true }) + jobEN?: string; + + @Field({ nullable: true }) + jobFR?: string; + + @Field({ nullable: true }) + business?: string; + + @Field({ nullable: true }) + employmentContractEN?: string; + + @Field({ nullable: true }) + employmentContractFR?: string; + + @Field({ nullable: true }) + startDateEN?: string; + + @Field({ nullable: true }) + startDateFR?: string; + + @Field({ nullable: true }) + endDateEN?: string; + + @Field({ nullable: true }) + endDateFR?: string; + + @Field({ nullable: true }) + month?: number; + + @Field({ nullable: true }) + typeEN?: string; + + @Field({ nullable: true }) + typeFR?: string; +} \ No newline at end of file diff --git a/backend/src/entities/inputs/project.input.ts b/backend/src/entities/inputs/project.input.ts new file mode 100644 index 00000000..8fae2517 --- /dev/null +++ b/backend/src/entities/inputs/project.input.ts @@ -0,0 +1,52 @@ +import { InputType, Field, Int } from "type-graphql"; + +@InputType() +export class CreateProjectInput { + @Field() + title: string; + + @Field() + descriptionEN: string; + + @Field() + descriptionFR: string; + + @Field() + typeDisplay: string; + + @Field({ nullable: true }) + github?: string; + + @Field() + contentDisplay: string; + + @Field(() => [Number]) + skillIds: number[]; +} + +@InputType() +export class UpdateProjectInput { + @Field(() => Int) + id: number; + + @Field({ nullable: true }) + title?: string; + + @Field({ nullable: true }) + descriptionFR?: string; + + @Field({ nullable: true }) + descriptionEN?: string; + + @Field({ nullable: true }) + github?: string; + + @Field({ nullable: true }) + typeDisplay?: string; + + @Field({ nullable: true }) + contentDisplay?: string; + + @Field(() => [Int], { nullable: true }) + skillIds?: number[]; +} \ No newline at end of file diff --git a/backend/src/entities/inputs/skill.input.ts b/backend/src/entities/inputs/skill.input.ts new file mode 100644 index 00000000..79831db6 --- /dev/null +++ b/backend/src/entities/inputs/skill.input.ts @@ -0,0 +1,43 @@ +import { InputType, Field, Int } from "type-graphql"; + +@InputType() +export class CreateCategoryInput { + @Field() + categoryEN: string; + + @Field() + categoryFR: string; +} + +@InputType() +export class CreateSkillInput { + @Field() + name: string; + + @Field() + image: string; + + @Field(() => Int) + categoryId: number; +} + +@InputType() +export class UpdateCategoryInput { + @Field({ nullable: true }) + categoryEN?: string; + + @Field({ nullable: true }) + categoryFR?: string; +} + +@InputType() +export class UpdateSkillInput { + @Field({ nullable: true }) + name?: string; + + @Field({ nullable: true }) + image?: string; + + @Field(() => Int) + categoryId?: number; +} \ No newline at end of file diff --git a/backend/src/entities/inputs/user.input.ts b/backend/src/entities/inputs/user.input.ts new file mode 100644 index 00000000..d8c5f2db --- /dev/null +++ b/backend/src/entities/inputs/user.input.ts @@ -0,0 +1,26 @@ +import { InputType, Field } from "type-graphql"; +import { UserRole } from "../user.entity"; + +@InputType() +export class CreateUserInput { + @Field() + firstname: string; + + @Field() + lastname: string; + + @Field() + email: string; + + @Field() + role: UserRole; +} + +@InputType() +export class LoginInput { + @Field() + email!: string; + + @Field() + password!: string; +} \ No newline at end of file diff --git a/backend/src/entities/project.entity.ts b/backend/src/entities/project.entity.ts new file mode 100644 index 00000000..77160ec3 --- /dev/null +++ b/backend/src/entities/project.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, + CreateDateColumn, + UpdateDateColumn, + } from "typeorm"; + import { Field, ID, ObjectType } from "type-graphql"; + import { SkillSubItem } from "./skillSubItem.entity"; +import { Length } from "class-validator"; + +@ObjectType() +@Entity() +export class Project { + @Field(() => ID) + id: number; + + @Field() + title: string; + + @Field() + descriptionFR: string; + + @Field() + descriptionEN: string; + + @Field() + typeDisplay: string; + + @Field(() => String, { nullable: true }) + github: string | null; + + @Field() + contentDisplay: string; + + @Field(() => [SkillSubItem]) + skills: SkillSubItem[]; + +} + diff --git a/backend/src/entities/response.types.ts b/backend/src/entities/response.types.ts new file mode 100644 index 00000000..53a7fc5a --- /dev/null +++ b/backend/src/entities/response.types.ts @@ -0,0 +1,153 @@ +import { ObjectType, Field, Int } from "type-graphql"; +import { Project } from "./project.entity"; +import { Skill } from "./skill.entity"; +import { SkillSubItem } from "./skillSubItem.entity"; +import { Education } from "./education.entity"; +import { Experience } from "./experience.entity"; +import { User } from "./user.entity"; + +@ObjectType() +export class Response { + @Field(() => Int) + code: number; + + @Field() + message: string; +} + +@ObjectType() +export class ProjectResponse { + @Field(() => Int) + code: number; + + @Field() + message: string; + + @Field(() => Project, { nullable: true }) + project?: Project; +} + +@ObjectType() +export class ProjectsResponse { + @Field(() => Int) + code: number; + + @Field() + message: string; + + @Field(() => [Project], { nullable: true }) + projects?: Project[]; +} + +@ObjectType() +export class CategoryResponse extends Response { + @Field(() => [Skill], { nullable: true }) + categories?: Skill[]; +} + +@ObjectType() +export class SubItemResponse extends Response { + @Field(() => [SkillSubItem], { nullable: true }) + subItems?: SkillSubItem[]; +} + +@ObjectType() +export class EducationsResponse extends Response { + @Field(() => [Education], { nullable: true }) + educations?: Education[]; +} + +@ObjectType() +export class EducationResponse extends Response { + @Field(() => Education, { nullable: true }) + education?: Education; +} + +@ObjectType() +export class ExperiencesResponse extends Response { + @Field(() => [Experience], { nullable: true }) + experiences?: Experience[]; +} + +@ObjectType() +export class ExperienceResponse extends Response { + @Field(() => Experience, { nullable: true }) + experience?: Experience; +} + +@ObjectType() +export class UserResponse extends Response{ + @Field(() => User, { nullable: true }) + user?: User; +} + +@ObjectType() +export class UsersResponse extends Response{ + @Field(() => [User], { nullable: true }) + users?: User[]; +} + +@ObjectType() +export class LoginResponse extends Response { + @Field(() => String, { nullable: true }) + token?: string; +} + +export @ObjectType() +class GlobalStats { + @Field(() => Int) + totalUsers!: number; + + @Field(() => Int) + totalProjects!: number; + + @Field(() => Int) + totalSkills!: number; + + @Field(() => Int) + totalEducations!: number; + + @Field(() => Int) + totalExperiences!: number; + + @Field(() => Int) + usersByRoleAdmin!: number; + @Field(() => Int) + usersByRoleEditor!: number; + @Field(() => Int) + usersByRoleView!: number; +} + +@ObjectType() +export class GlobalStatsResponse extends Response { + @Field(() => GlobalStats, { nullable: true }) + stats?: GlobalStats; +} + +@ObjectType() +export class BackupFileInfo { + @Field() + fileName!: string; + + @Field(() => Int) + sizeBytes!: number; + + @Field() + createdAt!: Date; + + @Field() + modifiedAt!: Date; +} + +@ObjectType() +export class BackupFilesResponse extends Response { + + @Field(() => [BackupFileInfo], { nullable: true }) + files?: BackupFileInfo[]; +} + +@ObjectType() +export class BackupResponse extends Response { + @Field() + path: string; +} \ No newline at end of file diff --git a/backend/src/entities/skill.entity.ts b/backend/src/entities/skill.entity.ts new file mode 100644 index 00000000..e0212124 --- /dev/null +++ b/backend/src/entities/skill.entity.ts @@ -0,0 +1,24 @@ +import { + Entity, +} from "typeorm"; +import { Field, + ID, + ObjectType +} from "type-graphql"; +import { SkillSubItem } from "./skillSubItem.entity"; + +@ObjectType() +@Entity() +export class Skill { + @Field(() => ID) + id: number; + + @Field() + categoryEN: string; + + @Field() + categoryFR: string; + + @Field(() => [SkillSubItem]) + skills: SkillSubItem[]; +} \ No newline at end of file diff --git a/backend/src/entities/skillSubItem.entity.ts b/backend/src/entities/skillSubItem.entity.ts new file mode 100644 index 00000000..f936a545 --- /dev/null +++ b/backend/src/entities/skillSubItem.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + } from "typeorm"; + import { Field, ID, ObjectType } from "type-graphql"; + import { Skill } from "./skill.entity"; + +@ObjectType() +export class SkillSubItem { + @Field(() => ID) + id: number; + + @Field() + name: string; + + @Field() + image: string; + + @Field() + categoryId: number; +} \ No newline at end of file diff --git a/backend/src/entities/user.entity.ts b/backend/src/entities/user.entity.ts index 5acfc37d..011acf60 100644 --- a/backend/src/entities/user.entity.ts +++ b/backend/src/entities/user.entity.ts @@ -1 +1,33 @@ -// User Entity.... \ No newline at end of file +import { ObjectType, Field, ID, registerEnumType } from "type-graphql"; + +export enum UserRole { + admin = "admin", + editor = "editor", + view = "view", +} + +registerEnumType(UserRole, { + name: "Role", + description: "User roles", +}); + +@ObjectType() +export class User { + @Field(() => ID) + id: number; + + @Field() + firstname: string; + + @Field() + lastname: string; + + @Field() + email: string; + + @Field(() => UserRole) + role!: UserRole; + + @Field() + isPasswordChange: boolean; +} \ No newline at end of file diff --git a/backend/src/images/car-voiture-1.jpg b/backend/src/images/captcha/car-voiture-1.jpg similarity index 100% rename from backend/src/images/car-voiture-1.jpg rename to backend/src/images/captcha/car-voiture-1.jpg diff --git a/backend/src/images/car-voiture-10.png b/backend/src/images/captcha/car-voiture-10.png similarity index 100% rename from backend/src/images/car-voiture-10.png rename to backend/src/images/captcha/car-voiture-10.png diff --git a/backend/src/images/car-voiture-2.jpg b/backend/src/images/captcha/car-voiture-2.jpg similarity index 100% rename from backend/src/images/car-voiture-2.jpg rename to backend/src/images/captcha/car-voiture-2.jpg diff --git a/backend/src/images/car-voiture-3.jpg b/backend/src/images/captcha/car-voiture-3.jpg similarity index 100% rename from backend/src/images/car-voiture-3.jpg rename to backend/src/images/captcha/car-voiture-3.jpg diff --git a/backend/src/images/car-voiture-4.jpg b/backend/src/images/captcha/car-voiture-4.jpg similarity index 100% rename from backend/src/images/car-voiture-4.jpg rename to backend/src/images/captcha/car-voiture-4.jpg diff --git a/backend/src/images/car-voiture-5.jpg b/backend/src/images/captcha/car-voiture-5.jpg similarity index 100% rename from backend/src/images/car-voiture-5.jpg rename to backend/src/images/captcha/car-voiture-5.jpg diff --git a/backend/src/images/car-voiture-6.png b/backend/src/images/captcha/car-voiture-6.png similarity index 100% rename from backend/src/images/car-voiture-6.png rename to backend/src/images/captcha/car-voiture-6.png diff --git a/backend/src/images/car-voiture-7.jpg b/backend/src/images/captcha/car-voiture-7.jpg similarity index 100% rename from backend/src/images/car-voiture-7.jpg rename to backend/src/images/captcha/car-voiture-7.jpg diff --git a/backend/src/images/car-voiture-8.jpg b/backend/src/images/captcha/car-voiture-8.jpg similarity index 100% rename from backend/src/images/car-voiture-8.jpg rename to backend/src/images/captcha/car-voiture-8.jpg diff --git a/backend/src/images/car-voiture-9.jpg b/backend/src/images/captcha/car-voiture-9.jpg similarity index 100% rename from backend/src/images/car-voiture-9.jpg rename to backend/src/images/captcha/car-voiture-9.jpg diff --git a/backend/src/images/cat-chat-1.jpg b/backend/src/images/captcha/cat-chat-1.jpg similarity index 100% rename from backend/src/images/cat-chat-1.jpg rename to backend/src/images/captcha/cat-chat-1.jpg diff --git a/backend/src/images/cat-chat-10.jpg b/backend/src/images/captcha/cat-chat-10.jpg similarity index 100% rename from backend/src/images/cat-chat-10.jpg rename to backend/src/images/captcha/cat-chat-10.jpg diff --git a/backend/src/images/cat-chat-11.png b/backend/src/images/captcha/cat-chat-11.png similarity index 100% rename from backend/src/images/cat-chat-11.png rename to backend/src/images/captcha/cat-chat-11.png diff --git a/backend/src/images/cat-chat-2.jpg b/backend/src/images/captcha/cat-chat-2.jpg similarity index 100% rename from backend/src/images/cat-chat-2.jpg rename to backend/src/images/captcha/cat-chat-2.jpg diff --git a/backend/src/images/cat-chat-3.jpg b/backend/src/images/captcha/cat-chat-3.jpg similarity index 100% rename from backend/src/images/cat-chat-3.jpg rename to backend/src/images/captcha/cat-chat-3.jpg diff --git a/backend/src/images/cat-chat-4.jpg b/backend/src/images/captcha/cat-chat-4.jpg similarity index 100% rename from backend/src/images/cat-chat-4.jpg rename to backend/src/images/captcha/cat-chat-4.jpg diff --git a/backend/src/images/cat-chat-5.jpg b/backend/src/images/captcha/cat-chat-5.jpg similarity index 100% rename from backend/src/images/cat-chat-5.jpg rename to backend/src/images/captcha/cat-chat-5.jpg diff --git a/backend/src/images/cat-chat-6.jpg b/backend/src/images/captcha/cat-chat-6.jpg similarity index 100% rename from backend/src/images/cat-chat-6.jpg rename to backend/src/images/captcha/cat-chat-6.jpg diff --git a/backend/src/images/cat-chat-7.jpg b/backend/src/images/captcha/cat-chat-7.jpg similarity index 100% rename from backend/src/images/cat-chat-7.jpg rename to backend/src/images/captcha/cat-chat-7.jpg diff --git a/backend/src/images/cat-chat-8.jpg b/backend/src/images/captcha/cat-chat-8.jpg similarity index 100% rename from backend/src/images/cat-chat-8.jpg rename to backend/src/images/captcha/cat-chat-8.jpg diff --git a/backend/src/images/cat-chat-9.jpg b/backend/src/images/captcha/cat-chat-9.jpg similarity index 100% rename from backend/src/images/cat-chat-9.jpg rename to backend/src/images/captcha/cat-chat-9.jpg diff --git a/backend/src/images/dog-chien-1.jpg b/backend/src/images/captcha/dog-chien-1.jpg similarity index 100% rename from backend/src/images/dog-chien-1.jpg rename to backend/src/images/captcha/dog-chien-1.jpg diff --git a/backend/src/images/dog-chien-10.jpg b/backend/src/images/captcha/dog-chien-10.jpg similarity index 100% rename from backend/src/images/dog-chien-10.jpg rename to backend/src/images/captcha/dog-chien-10.jpg diff --git a/backend/src/images/dog-chien-2.jpg b/backend/src/images/captcha/dog-chien-2.jpg similarity index 100% rename from backend/src/images/dog-chien-2.jpg rename to backend/src/images/captcha/dog-chien-2.jpg diff --git a/backend/src/images/dog-chien-3.jpg b/backend/src/images/captcha/dog-chien-3.jpg similarity index 100% rename from backend/src/images/dog-chien-3.jpg rename to backend/src/images/captcha/dog-chien-3.jpg diff --git a/backend/src/images/dog-chien-4.jpg b/backend/src/images/captcha/dog-chien-4.jpg similarity index 100% rename from backend/src/images/dog-chien-4.jpg rename to backend/src/images/captcha/dog-chien-4.jpg diff --git a/backend/src/images/dog-chien-5.jpg b/backend/src/images/captcha/dog-chien-5.jpg similarity index 100% rename from backend/src/images/dog-chien-5.jpg rename to backend/src/images/captcha/dog-chien-5.jpg diff --git a/backend/src/images/dog-chien-6.jpg b/backend/src/images/captcha/dog-chien-6.jpg similarity index 100% rename from backend/src/images/dog-chien-6.jpg rename to backend/src/images/captcha/dog-chien-6.jpg diff --git a/backend/src/images/dog-chien-7.jpg b/backend/src/images/captcha/dog-chien-7.jpg similarity index 100% rename from backend/src/images/dog-chien-7.jpg rename to backend/src/images/captcha/dog-chien-7.jpg diff --git a/backend/src/images/dog-chien-8.jpg b/backend/src/images/captcha/dog-chien-8.jpg similarity index 100% rename from backend/src/images/dog-chien-8.jpg rename to backend/src/images/captcha/dog-chien-8.jpg diff --git a/backend/src/images/dog-chien-9.jpg b/backend/src/images/captcha/dog-chien-9.jpg similarity index 100% rename from backend/src/images/dog-chien-9.jpg rename to backend/src/images/captcha/dog-chien-9.jpg diff --git a/backend/src/images/logos/github.png b/backend/src/images/logos/github.png new file mode 100644 index 00000000..6cb3b705 Binary files /dev/null and b/backend/src/images/logos/github.png differ diff --git a/backend/src/index copy.ts b/backend/src/index copy.ts new file mode 100644 index 00000000..50697be9 --- /dev/null +++ b/backend/src/index copy.ts @@ -0,0 +1,206 @@ +import 'reflect-metadata'; +import express from "express"; +import http from "http"; +import { ApolloServer } from "@apollo/server"; +import { expressMiddleware } from "@apollo/server/express4"; +import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; +import cors from "cors"; +import { buildSchema } from "type-graphql"; +import { ContactResolver } from "./resolvers/contact.resolver"; +import { GenerateImageResolver } from "./resolvers/generateImage.resolver"; +import path from 'path'; +import { CaptchaResolver } from './resolvers/captcha.resolver'; +import { captchaImageMap, cleanUpExpiredCaptchas } from './CaptchaMap'; +import { SkillResolver } from './resolvers/skill.resolver'; +import { checkApiKey } from './lib/checkApiKey'; +import { ExperienceResolver } from './resolvers/experience.resolver'; +import { EducationResolver } from './resolvers/education.resolver'; +import { ProjectResolver } from './resolvers/project.resolver'; +import { UserResolver } from './resolvers/user.resolver'; +import Cookies from "cookies"; +import { PrismaClient } from "@prisma/client"; +import { User, UserRole } from "./entities/user.entity"; +import { jwtVerify } from "jose"; +import "dotenv/config"; +import { customAuthChecker } from "./lib/authChecker"; +import { AdminResolver } from './resolvers/admin.resolver'; +import { generateBadgeSvg } from './lib/badgeGenerator'; + +const prisma = new PrismaClient(); + +export interface JwtPayload { + userId: number; +} + +export interface MyContext { + req: express.Request; + res: express.Response; + apiKey: string | undefined; + cookies: Cookies; + user: User | null; +} + +const app = express(); +const httpServer = http.createServer(app); + +async function main() { + const schema = await buildSchema({ + resolvers: [ + ContactResolver, + GenerateImageResolver, + CaptchaResolver, + SkillResolver, + ProjectResolver, + ExperienceResolver, + EducationResolver, + UserResolver, + AdminResolver, + ], + validate: false, + authChecker: customAuthChecker, + }); + + const server = new ApolloServer({ + schema, + plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + }); + + await server.start(); + + app.get('/dynamic-images/:id', (req, res) => { + const imageId = req.params.id; + const filename = captchaImageMap[imageId]; + if (filename) { + const imagePath = path.join(__dirname, 'images', filename); + res.sendFile(imagePath); + } else { + res.status(404).send('Image not found'); + } + }); + + app.get('/badge/:label/:message/:messageColor/:labelColor', (req, res) => { + const { label, message, messageColor, labelColor } = req.params; + const { logo, logoColor, logoPosition } = req.query; + + try { + const decodedLabel = decodeURIComponent(label); + const decodedMessage = decodeURIComponent(message); + const decodedMessageColor = decodeURIComponent(messageColor); + const decodedLabelColor = decodeURIComponent(labelColor); + + const finalLogoPosition: 'left' | 'right' = + logoPosition === 'right' ? 'right' : 'left'; + + const svg = generateBadgeSvg( + decodedLabel, + decodedMessage, + decodedMessageColor, + decodedLabelColor, + logo ? String(logo) : undefined, + logoColor ? String(logoColor) : undefined, + finalLogoPosition + ); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(svg); + } catch (error) { + console.error("Erreur lors de la génération du badge SVG:", error); + res.status(500).send('Error'); + } + }); + + app.get('/badge/stats/projects-count', async (req, res) => { + try { + const projectCount = await prisma.project.count(); + const svg = generateBadgeSvg( + 'Projets', + String(projectCount), + '4CAF50', + '2F4F4F', + 'JavaScript', + 'white', + 'right' + ); + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(svg); + } catch (error) { + console.error("Erreur lors de la génération du badge des projets:", error); + res.status(500).send('Error'); + } + }); + + app.use( + "/graphql", + cors({ + origin: ["http://localhost:3000"], + credentials: true, + }), + express.json(), + expressMiddleware(server, { + context: async ({ req, res }) => { + const cookies = new Cookies(req, res); + console.log("cookies:", cookies.get("jwt")); + let user: User | null = null; + + const token = cookies.get("jwt"); + console.log("Token du cookie:", token ? "Présent" : "Absent"); + + if (token && process.env.JWT_SECRET) { + try { + const { payload } = await jwtVerify( + token, + new TextEncoder().encode(process.env.JWT_SECRET) + ); + + console.log("Payload du token décodé:", payload); + + const prismaUser = await prisma.user.findUnique({ + where: { id: payload.userId } + }); + + if (prismaUser) { + user = { + id: prismaUser.id, + email: prismaUser.email, + firstname: prismaUser.firstname, + lastname: prismaUser.lastname, + role: prismaUser.role as UserRole, + isPasswordChange: prismaUser.isPasswordChange, + }; + } + + } catch (err) { + console.error("Erreur de vérification JWT:", err); // Log l'erreur complète + cookies.set("jwt", "", { expires: new Date(0), httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const }); + } + } + + const apiKeyHeader = req.headers['x-api-key']; + const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; + + // const operationName = req.body.operationName || (req.body.query && req.body.query.match(/(mutation|query)\s+(\w+)/)?.[2]); + + if (!apiKey) { + throw new Error('Unauthorized: x-api-key header is missing.'); + } + + if (apiKey) { + await checkApiKey(apiKey); + } + + return { req, res, apiKey, cookies, user }; + }, + }) + ); + + setInterval(cleanUpExpiredCaptchas, 15 * 60 * 1000); + + await new Promise((resolve) => + httpServer.listen({ port: 4000 }, resolve) + ); + console.log(`🚀 Server lancé sur http://localhost:4000/`); +} + +main(); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 43091cb7..88831c21 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,4 @@ -import 'reflect-metadata'; +import 'reflect-metadata'; import express from "express"; import http from "http"; import { ApolloServer } from "@apollo/server"; @@ -7,45 +7,159 @@ import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHt import cors from "cors"; import { buildSchema } from "type-graphql"; import { ContactResolver } from "./resolvers/contact.resolver"; -import { GenerateImageResolver } from "./resolvers/generateImage.resolver"; import path from 'path'; import { CaptchaResolver } from './resolvers/captcha.resolver'; import { captchaImageMap, cleanUpExpiredCaptchas } from './CaptchaMap'; +import { SkillResolver } from './resolvers/skill.resolver'; +import { checkApiKey } from './lib/checkApiKey'; +import { ExperienceResolver } from './resolvers/experience.resolver'; +import { EducationResolver } from './resolvers/education.resolver'; +import { ProjectResolver } from './resolvers/project.resolver'; +import { UserResolver } from './resolvers/user.resolver'; +import Cookies from "cookies"; +import { PrismaClient } from "@prisma/client"; +import { User, UserRole } from "./entities/user.entity"; +import { jwtVerify } from "jose"; +import "dotenv/config"; +import { customAuthChecker } from "./lib/authChecker"; +import { AdminResolver } from './resolvers/admin.resolver'; +import { generateBadgeSvg } from './lib/badgeGenerator'; +import { loadedLogos, loadLogos } from './lib/logoLoader'; + +const prisma = new PrismaClient(); + +export interface JwtPayload { + userId: number; +} export interface MyContext { req: express.Request; res: express.Response; apiKey: string | undefined; + cookies: Cookies; + user: User | null; } const app = express(); const httpServer = http.createServer(app); async function main() { - const schema = await buildSchema({ - resolvers: [ContactResolver, GenerateImageResolver, CaptchaResolver], + resolvers: [ + ContactResolver, + CaptchaResolver, + SkillResolver, + ProjectResolver, + ExperienceResolver, + EducationResolver, + UserResolver, + AdminResolver, + ], validate: false, + authChecker: customAuthChecker, }); const server = new ApolloServer({ schema, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], }); - + await server.start(); app.get('/dynamic-images/:id', (req, res) => { const imageId = req.params.id; const filename = captchaImageMap[imageId]; if (filename) { - const imagePath = path.join(__dirname, 'images', filename); + const imagePath = path.join(__dirname, 'images/captcha', filename); res.sendFile(imagePath); } else { res.status(404).send('Image not found'); } }); + app.get('/badge/:label/:message/:messageColor/:labelColor/:logo', (req, res) => { + const { label, message, messageColor, labelColor, logo } = req.params; + const { logoColor, logoPosition } = req.query; + + try { + const decodedLabel = decodeURIComponent(label); + const decodedMessage = decodeURIComponent(message); + const decodedMessageColor = decodeURIComponent(messageColor); + const decodedLabelColor = decodeURIComponent(labelColor); + + const finalLogoPosition: 'left' | 'right' = + logoPosition === 'right' ? 'right' : 'left'; + + let logoDataForBadge: { base64: string; mimeType: string } | undefined; + if (logo) { + logoDataForBadge = loadedLogos.get(String(logo).toLowerCase()); + if (!logoDataForBadge) { + console.warn(`Logo personnalisé '${logo}' non trouvé dans les logos chargés.`); + } + } + + const svg = generateBadgeSvg( + decodedLabel, + decodedMessage, + decodedMessageColor, + decodedLabelColor, + logoDataForBadge, + logoColor ? String(logoColor) : undefined, + finalLogoPosition + ); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(svg); + } catch (error) { + console.error("Erreur lors de la génération du badge SVG:", error); + res.status(500).send('Error'); + } + }); + + app.get('/badge/stats/projects-count', async (req, res) => { + try { + const projectCount = await prisma.project.count(); + const logoData = loadedLogos.get('github'); + if (!logoData) console.warn("Logo 'github' non trouvé pour le badge projets."); + + const svg = generateBadgeSvg( + 'Projets', + String(projectCount), + '4CAF50', + '2F4F4F', + logoData, + 'white', + 'right' + ); + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(svg); + } catch (error) { + console.error("Erreur lors de la génération du badge des projets:", error); + res.status(500).send('Error'); + } + }); + + app.get('/upload/:type/:filename', (req, res) => { + const { type, filename } = req.params; + + if (!['image', 'video'].includes(type)) { + return res.status(400).send('Invalid type. Use "image" or "video".'); + } + + const filePath = path.join(__dirname, '.', 'uploads', `${type}s`, filename); + + res.sendFile(filePath, (err) => { + if (err) { + if (!res.headersSent) { + console.error(`Fichier non trouvé : ${filePath}`); + return res.status(404).send('Fichier non trouvé'); + } + } + }); + }); + app.use( "/graphql", cors({ @@ -55,10 +169,57 @@ async function main() { express.json(), expressMiddleware(server, { context: async ({ req, res }) => { - const apiKey = req.headers['x-api-key']; - console.log("apiKey", apiKey); - console.log("process.env.API_KEY", process.env.API_KEY); - return { req, res, apiKey: apiKey as string | undefined }; + const cookies = new Cookies(req, res); + // console.log("cookies:", cookies.get("jwt")); + let user: User | null = null; + + const token = cookies.get("jwt"); + // console.log("Token du cookie:", token ? "Présent" : "Absent"); + + if (token && process.env.JWT_SECRET) { + try { + const { payload } = await jwtVerify( + token, + new TextEncoder().encode(process.env.JWT_SECRET) + ); + + // console.log("Payload du token décodé:", payload); + + const prismaUser = await prisma.user.findUnique({ + where: { id: payload.userId } + }); + + if (prismaUser) { + user = { + id: prismaUser.id, + email: prismaUser.email, + firstname: prismaUser.firstname, + lastname: prismaUser.lastname, + role: prismaUser.role as UserRole, + isPasswordChange: prismaUser.isPasswordChange, + }; + } + + } catch (err) { + console.error("Erreur de vérification JWT:", err); // Log l'erreur complète + cookies.set("jwt", "", { expires: new Date(0), httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const }); + } + } + + const apiKeyHeader = req.headers['x-api-key']; + const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; + + // const operationName = req.body.operationName || (req.body.query && req.body.query.match(/(mutation|query)\s+(\w+)/)?.[2]); + + if (!apiKey) { + throw new Error('Unauthorized: x-api-key header is missing.'); + } + + if (apiKey) { + await checkApiKey(apiKey); + } + + return { req, res, apiKey, cookies, user }; }, }) ); @@ -71,4 +232,5 @@ async function main() { console.log(`🚀 Server lancé sur http://localhost:4000/`); } -main(); \ No newline at end of file +main(); +loadLogos(); \ No newline at end of file diff --git a/backend/src/lib/authChecker.ts b/backend/src/lib/authChecker.ts new file mode 100644 index 00000000..58730388 --- /dev/null +++ b/backend/src/lib/authChecker.ts @@ -0,0 +1,57 @@ +import { AuthChecker } from "type-graphql"; +import { MyContext } from "../index"; // Importe MyContext depuis ton fichier principal du serveur +import { UserRole } from "../entities/user.entity"; // Importe UserRole (ton enum) + +/** + * Fonction customAuthChecker pour Type-GraphQL. + * Elle est appelée par le décorateur @Authorized() pour vérifier les permissions. + * + * @param {object} param0 - Contient l'objet de contexte (context) de la requête. + * @param {string[]} roles - Tableau des rôles requis pour accéder au resolver (passés à @Authorized()). + * @returns {boolean} True si l'utilisateur est autorisé, False sinon. + */ + +// export const customAuthChecker: AuthChecker = ( +// { context }, +// roles +// ) => { + +// if (!context.user) { +// // console.log("AuthChecker: User not logged in (context.user is null)."); +// return false; +// } + +// if (roles.length > 0) { +// if (!roles.includes(context.user.role)) { +// // console.log( +// // `AuthChecker: User '${context.user.email}' (Role: '${context.user.role}') is not in required roles: [${roles.join(', ')}].` +// // ); +// return false; +// } +// } + +// // console.log(`AuthChecker: User '${context.user.email}' (Role: '${context.user.role}') is authorized.`); +// return true; +// }; + +export const customAuthChecker = ( + { context }: { context: MyContext }, + roles: string[] +): boolean => { + if (!context.user) { + // console.log("AuthChecker: User not logged in (context.user is null)."); + return false; + } + + if (roles.length > 0) { + if (!roles.includes(context.user.role)) { + // console.log( + // `AuthChecker: User '${context.user.email}' (Role: '${context.user.role}') is not in required roles: [${roles.join(', ')}].` + // ); + return false; + } + } + +// console.log(`AuthChecker: User '${context.user.email}' (Role: '${context.user.role}') is authorized.`); + return true; +}; \ No newline at end of file diff --git a/backend/src/lib/badgeGenerator copy.ts b/backend/src/lib/badgeGenerator copy.ts new file mode 100644 index 00000000..218ce931 --- /dev/null +++ b/backend/src/lib/badgeGenerator copy.ts @@ -0,0 +1,137 @@ +import * as simpleIcons from 'simple-icons'; + +/** + * Génère un SVG pour un badge de style Shields.io (flat-square) avec plus de personnalisation. + * + * @param label Le texte affiché à gauche du badge (ex: "Langage"). + * @param message Le texte affiché à droite du badge (ex: "JavaScript"). + * @param messageColor La couleur d'arrière-plan de la partie message (ex: "blue", "33FF66"). + * @param labelColor La couleur d'arrière-plan de la partie label (ex: "grey", "555"). Optionnel, par défaut #555. + * @param logoSlug Le "slug" du logo SimpleIcons (ex: "github", "react"). Optionnel. + * @param logoColor La couleur du logo. Optionnel, par défaut "white". + * @param logoPosition Position du logo : 'left' ou 'right' (défaut 'left'). + * @returns La chaîne de caractères représentant le code SVG du badge. + */ +export function generateBadgeSvg( + label: string, + message: string, + messageColor: string, + labelColor: string = '555', // Nouvelle couleur par défaut pour le label + logoSlug?: string, + logoColor: string = 'white', + logoPosition: 'left' | 'right' = 'left' // Nouvelle option de position du logo +): string { + // Fonction utilitaire pour échapper le texte SVG + const escapeSvgText = (text: string) => + text.replace(/[<>&"']/g, (c) => { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '"': return '"'; + case "'": return '''; + default: return c; + } + }); + + const escapedLabel = escapeSvgText(label); + const escapedMessage = escapeSvgText(message); + + const baseFontSize = 11; + const padding = 10; // Espacement horizontal (gauche et droite pour le texte) + const textY = (20 / 2) + (baseFontSize / 2) - 2; // Position Y pour centrer le texte verticalement + + let logoWidth = 0; + let logoSvgContent = ''; + + if (logoSlug) { + const iconKey = `si${logoSlug.charAt(0).toUpperCase() + logoSlug.slice(1)}`; + const icon = (simpleIcons as any)[iconKey]; + + if (icon && icon.path) { + logoSvgContent = icon.path; + logoWidth = 14; // Largeur standard du logo + } else { + console.warn(`Logo '${logoSlug}' non trouvé ou chemin SVG manquant.`); + logoWidth = 0; + } + } + + // Largeurs des segments de texte (approximations) + const labelTextActualWidth = (escapedLabel.length * 6) + padding; + const messageTextActualWidth = (escapedMessage.length * 6) + padding; + + // Calcul des largeurs finales des segments en fonction du logo et de sa position + let labelSegmentWidth = labelTextActualWidth; + let messageSegmentWidth = messageTextActualWidth; + + // Ajustement des largeurs de segment pour faire de la place au logo + if (logoSvgContent) { + if (logoPosition === 'left') { + labelSegmentWidth += logoWidth + (padding / 2); // Ajoute l'espace du logo au segment label + } else { // logoPosition === 'right' + messageSegmentWidth += logoWidth + (padding / 2); // Ajoute l'espace du logo au segment message + } + } + + const totalWidth = labelSegmentWidth + messageSegmentWidth; + const height = 20; + + // Positionnement du texte (X) - Calcul plus précis + let labelTextX = (padding / 2) + (labelTextActualWidth - padding) / 2; + let messageTextX = labelSegmentWidth + (padding / 2) + (messageTextActualWidth - padding) / 2; + + // Décalage du texte pour faire de la place au logo si il est dans le même segment + if (logoSvgContent) { + if (logoPosition === 'left') { + labelTextX += (logoWidth + (padding / 2)) / 2; // Décale le texte du label vers la droite + } else { // logoPosition === 'right' + messageTextX -= (logoWidth + (padding / 2)) / 2; // Décale le texte du message vers la gauche + } + } + + // Positionnement du logo (X) + let logoX = 0; + if (logoSvgContent) { + if (logoPosition === 'left') { + logoX = padding / 2; // Logo au début du segment label + } else { // logoPosition === 'right' + logoX = labelSegmentWidth + messageSegmentWidth - logoWidth - (padding / 2); // Logo à la fin du segment message + } + } + + // **** CORRECTION CLÉ ICI **** + // Utilise directement labelColor et messageColor sans préfixe '#', + // car SVG peut gérer les noms de couleurs (red, white) et les hex codes (ex: #FF0000) + // quand le # est déjà inclus dans l'hex code lui-même. + const finalLabelColor = labelColor; + const finalMessageColor = messageColor; + + return ` + + + + + + + + + + + + + ${logoSvgContent && logoPosition === 'left' ? ` + + ` : ''} + ${escapedLabel} + ${escapedLabel} + + ${logoSvgContent && logoPosition === 'right' ? ` + + ` : ''} + ${escapedMessage} + ${escapedMessage} + + + `; +} \ No newline at end of file diff --git a/backend/src/lib/badgeGenerator.ts b/backend/src/lib/badgeGenerator.ts new file mode 100644 index 00000000..9bd965c2 --- /dev/null +++ b/backend/src/lib/badgeGenerator.ts @@ -0,0 +1,126 @@ + +/** + * Génère un SVG pour un badge de style Shields.io (flat-square) avec plus de personnalisation. + * + * @param label Le texte affiché à gauche du badge (ex: "Langage"). + * @param message Le texte affiché à droite du badge (ex: "JavaScript"). + * @param messageColor La couleur d'arrière-plan de la partie message (ex: "blue", "33FF66"). + * @param labelColor La couleur d'arrière-plan de la partie label (ex: "grey", "555"). Optionnel, par défaut #555. + * @param logoData Objet contenant les données Base64 du logo et son type MIME. Optionnel. + * Ex: { base64: "...", mimeType: "image/png" } + * @param logoColor La couleur du logo (sera ignorée pour les images PNG/JPG, utile pour les SVG). Optionnel, par défaut "white". + * @param logoPosition Position du logo : 'left' ou 'right' (défaut 'left'). + * @returns La chaîne de caractères représentant le code SVG du badge. + */ +export function generateBadgeSvg( + label: string, + message: string, + messageColor: string, + labelColor: string = '555', + logoData?: { base64: string; mimeType: string }, + logoColor: string = 'white', + logoPosition: 'left' | 'right' = 'left' +): string { + // Fonction utilitaire pour échapper le texte SVG + const escapeSvgText = (text: string) => + text.replace(/[<>&"']/g, (c) => { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '"': return '"'; + case "'": return '''; + default: return c; + } + }); + + const escapedLabel = escapeSvgText(label); + const escapedMessage = escapeSvgText(message); + + const baseFontSize = 11; + const padding = 10; // Espacement horizontal (gauche et droite pour le texte) + const textY = (20 / 2) + (baseFontSize / 2) - 2; // Position Y pour centrer le texte verticalement + + let logoWidth = 0; + let logoHref = ''; // Contient la chaîne "data:image/xxx;base64,..." + + if (logoData && logoData.base64 && logoData.mimeType) { + logoHref = `data:${logoData.mimeType};base64,${logoData.base64}`; + logoWidth = 14; // Largeur standard du logo + // Note: Pour les images raster (PNG/JPG), logoColor est ignoré ici. + // Pour les SVG, la couleur peut être intégrée dans le SVG lui-même par logoLoader si nécessaire, + // ou gérée par un remplacement si le SVG est simple (mais ce n'est plus le rôle de badgeGenerator). + } + + // Largeurs des segments de texte (approximations) + const labelTextActualWidth = (escapedLabel.length * 6) + padding; + const messageTextActualWidth = (escapedMessage.length * 6) + padding; + + // Calcul des largeurs finales des segments en fonction du logo et de sa position + let labelSegmentWidth = labelTextActualWidth; + let messageSegmentWidth = messageTextActualWidth; + + // Ajustement des largeurs de segment pour faire de la place au logo + if (logoHref) { // Utilise logoHref pour vérifier si un logo est présent + if (logoPosition === 'left') { + labelSegmentWidth += logoWidth + (padding / 2); // Ajoute l'espace du logo au segment label + } else { // logoPosition === 'right' + messageSegmentWidth += logoWidth + (padding / 2); // Ajoute l'espace du logo au segment message + } + } + + const totalWidth = labelSegmentWidth + messageSegmentWidth; + const height = 20; + + // Positionnement du texte (X) - Calcul plus précis + let labelTextX = (padding / 2) + (labelTextActualWidth - padding) / 2; + let messageTextX = labelSegmentWidth + (padding / 2) + (messageTextActualWidth - padding) / 2; + + // Décalage du texte pour faire de la place au logo si il est dans le même segment + if (logoHref) { + if (logoPosition === 'left') { + labelTextX += (logoWidth + (padding / 2)) / 2; // Décale le texte du label vers la droite + } else { // logoPosition === 'right' + messageTextX -= (logoWidth + (padding / 2)) / 2; // Décale le texte du message vers la gauche + } + } + + // Positionnement du logo (X) + let logoX = 0; + if (logoHref) { + if (logoPosition === 'left') { + logoX = padding / 2; // Logo au début du segment label + } else { // logoPosition === 'right' + logoX = labelSegmentWidth + messageSegmentWidth - logoWidth - (padding / 2); // Logo à la fin du segment message + } + } + + const finalLabelColor = labelColor; + const finalMessageColor = messageColor; + + return ` + + + + + + + + + + + + + + + ${logoHref ? ` + ` : ''} + ${escapedLabel} + ${escapedLabel} + + ${escapedMessage} + ${escapedMessage} + + + `; +} \ No newline at end of file diff --git a/backend/src/lib/checkApiKey.ts b/backend/src/lib/checkApiKey.ts index 69943523..4193cbcf 100644 --- a/backend/src/lib/checkApiKey.ts +++ b/backend/src/lib/checkApiKey.ts @@ -1,7 +1,7 @@ export const checkApiKey: (apiKey : string) => boolean = (apiKey : string): boolean => { - console.log("apiKey !== process.env.API_KEY", apiKey !== process.env.API_KEY); - console.log("apiKey", apiKey); - console.log("process.env.API_KEY", process.env.API_KEY); + // console.log("apiKey !== process.env.API_KEY", apiKey !== process.env.API_KEY); + // console.log("apiKey", apiKey); + // console.log("process.env.API_KEY", process.env.API_KEY); if (apiKey !== process.env.API_KEY) throw new Error('Unauthorized TOKEN API'); return true; diff --git a/backend/src/lib/db.ts b/backend/src/lib/db.ts deleted file mode 100644 index d4daa0ab..00000000 --- a/backend/src/lib/db.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DataSource } from "typeorm"; - -export default new DataSource({ - type: "postgres", - host : process.env.HOST_DB, - port: process.env.PORT_DB ? Number(process.env.PORT_DB) : 5432, - username : process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, - database: process.env.POSTGRES_DB, - synchronize : true, - logging: ["query", "error"], - entities : ['src/entities/*.ts'] -}); \ No newline at end of file diff --git a/backend/src/lib/db_test.ts b/backend/src/lib/db_test.ts deleted file mode 100644 index 91df4ca2..00000000 --- a/backend/src/lib/db_test.ts +++ /dev/null @@ -1 +0,0 @@ -//Config db test \ No newline at end of file diff --git a/backend/src/lib/generateSecurePassword.ts b/backend/src/lib/generateSecurePassword.ts new file mode 100644 index 00000000..20e48af5 --- /dev/null +++ b/backend/src/lib/generateSecurePassword.ts @@ -0,0 +1,26 @@ +export function generateSecurePassword(): string { + const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const lowercase = "abcdefghijklmnopqrstuvwxyz"; + const numbers = "0123456789"; + const symbols = "!@#$%^&*()_+[]{}|;:,.<>?"; + + const all = uppercase + lowercase + numbers + symbols; + + const getRandom = (str: string) => str[Math.floor(Math.random() * str.length)]; + + let password = [ + getRandom(uppercase), + getRandom(lowercase), + getRandom(numbers), + getRandom(symbols), + ]; + + while (password.length < 9) { + password.push(getRandom(all)); + } + + return password.sort(() => Math.random() - 0.5).join(""); +} + +// const passwordCreated = generateSecurePassword(); +// console.log("Generated password:", passwordCreated); \ No newline at end of file diff --git a/backend/src/lib/logoLoader.ts b/backend/src/lib/logoLoader.ts new file mode 100644 index 00000000..3b7e083f --- /dev/null +++ b/backend/src/lib/logoLoader.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export const loadedLogos = new Map(); + +const LOGOS_DIR = path.join(__dirname, '../images/logos'); +export function getMimeType(filePath: string): string { + if (!filePath) { + return 'application/octet-stream'; + } + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case '.png': return 'image/png'; + case '.svg': return 'image/svg+xml'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + default: return 'application/octet-stream'; + } +} + +export function loadLogos(): void { + console.log(`[loadLogos] Début du chargement des logos depuis : ${LOGOS_DIR}`); + try { + const files = fs.readdirSync(LOGOS_DIR); + console.log(`[loadLogos] Fichiers trouvés dans le dossier :`, files); + + if (files.length === 0) { + console.warn(`[loadLogos] Le dossier des logos est vide ou aucun fichier n'a été détecté : ${LOGOS_DIR}`); + } + + for (const file of files) { + const filePath = path.join(LOGOS_DIR, file); + const fileNameWithoutExt = path.parse(file).name.toLowerCase(); + const mimeType = getMimeType(filePath); + + console.log(`[loadLogos] Traitement du fichier: ${file}, Clé prévue: '${fileNameWithoutExt}', Type MIME: '${mimeType}'`); + + if (mimeType !== 'application/octet-stream') { + try { + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString('base64'); + loadedLogos.set(fileNameWithoutExt, { base64: base64Data, mimeType: mimeType }); + console.log(`[loadLogos] SUCCÈS : Logo '${fileNameWithoutExt}' chargé et ajouté à la map.`); + } catch (readError: any) { + console.error(`[loadLogos] ERREUR de lecture du fichier logo ${filePath}:`, readError.message || readError); + } + } else { + console.warn(`[loadLogos] Type de fichier non supporté pour le logo : ${file} (ignoré).`); + } + } + } catch (dirError: any) { + console.error(`[loadLogos] ERREUR GRAVE lors de la lecture du dossier des logos ${LOGOS_DIR}:`, dirError.message || dirError); + console.error("Assurez-vous que le dossier 'src/images/logos' existe et est accessible."); + } + console.log(`[loadLogos] FIN du chargement. Nombre total de logos chargés dans la map : ${loadedLogos.size}`); + console.log(`[loadLogos] Contenu de la map (clés uniquement) :`, Array.from(loadedLogos.keys())); +} \ No newline at end of file diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 00000000..b904402d --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; \ No newline at end of file diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts index cd6a2647..f4bd945a 100644 --- a/backend/src/mail/mail.service.ts +++ b/backend/src/mail/mail.service.ts @@ -22,23 +22,32 @@ const transporter = nodemailer.createTransport({ // }, } as SMTPTransport.Options); -export const sendEmail = async (email: string, subject: string, text: string, html: string): Promise => { +export const sendEmail = async ( + email: string, + subject: string, + text: string, + html: string, + sendToMe: boolean = false, +): Promise => { + + const recipient = sendToMe ? user : email; + const mailOptions = { from: user, - to : user, + to: recipient, subject, text, html, -}; + }; try { await transporter.sendMail(mailOptions); - return { label : "emailSent", message : "Email sent", status: true } + return { label: "emailSent", message: "Email sent", status: true }; } catch (error) { - return { - label : "emailNoSent", + return { + label: "emailNoSent", message: error instanceof Error ? error.message : 'Unknown error', - status: false - } + status: false, + }; } }; \ No newline at end of file diff --git a/backend/src/mail/structureMail.service.ts b/backend/src/mail/structureMail.service.ts index d952c383..f661e967 100644 --- a/backend/src/mail/structureMail.service.ts +++ b/backend/src/mail/structureMail.service.ts @@ -2,20 +2,41 @@ import { ContactFrom } from "../types/contact.types" export const structureMessageMeTEXT: (data: ContactFrom) => string = (data: ContactFrom): string => { return ` - ${data.message} - - ---------------------------------- - - Information sur l'email : - Email : ${data.email} - `; + ${data.message} + + ---------------------------------- + + Information sur l'email : + Email : ${data.email} + `; }; export const structureMessageMeHTML: (data: ContactFrom) => string = (data: ContactFrom): string => { return ` -

${data.message}

-
-

Information sur l'email :

-

Email : ${data.email}

+

${data.message}

+
+

Information sur l'email :

+

Email : ${data.email}

`; +}; + +export const structureMessageCreatedAccountTEXT: (firstname: string, plainPassword : string) => string = (firstname: string, plainPassword : string): string => { + return ` + Bonjour ${firstname}, + + Votre compte a été créé avec succès. + + Voici votre mot de passe temporaire : ${plainPassword} + + Merci de le changer dès votre première connexion. + `; +}; + +export const structureMessageCreatedAccountHTML: (firstname: string, plainPassword : string) => string = (firstname: string, plainPassword : string): string => { + return ` +

Bonjour ${firstname},

+

Votre compte a été créé avec succès.

+

Mot de passe temporaire : ${plainPassword}

+

Merci de le changer dès votre première connexion.

+ `; }; \ No newline at end of file diff --git a/backend/src/prisma/migrations/20250609220244_add_education_experience/migration.sql b/backend/src/prisma/migrations/20250609220244_add_education_experience/migration.sql new file mode 100644 index 00000000..830915fd --- /dev/null +++ b/backend/src/prisma/migrations/20250609220244_add_education_experience/migration.sql @@ -0,0 +1,88 @@ +-- CreateTable +CREATE TABLE `Project` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `title` VARCHAR(191) NOT NULL, + `descriptionEN` TEXT NOT NULL, + `descriptionFR` TEXT NOT NULL, + `typeDisplay` VARCHAR(191) NOT NULL, + `github` VARCHAR(191) NULL, + `contentDisplay` TEXT NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `SkillCategory` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `categoryEN` VARCHAR(191) NOT NULL, + `categoryFR` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Skill` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191) NOT NULL, + `image` VARCHAR(191) NOT NULL, + `categoryId` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `ProjectSkill` ( + `projectId` INTEGER NOT NULL, + `skillId` INTEGER NOT NULL, + + PRIMARY KEY (`projectId`, `skillId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Education` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `titleFR` VARCHAR(191) NOT NULL, + `titleEN` VARCHAR(191) NOT NULL, + `diplomaLevelEN` VARCHAR(191) NOT NULL, + `diplomaLevelFR` VARCHAR(191) NOT NULL, + `school` VARCHAR(191) NOT NULL, + `location` VARCHAR(191) NOT NULL, + `year` INTEGER NOT NULL, + `startDateEN` VARCHAR(191) NOT NULL, + `startDateFR` VARCHAR(191) NOT NULL, + `endDateEN` VARCHAR(191) NOT NULL, + `endDateFR` VARCHAR(191) NOT NULL, + `month` INTEGER NOT NULL, + `typeEN` VARCHAR(191) NOT NULL, + `typeFR` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Experience` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `jobEN` VARCHAR(191) NOT NULL, + `jobFR` VARCHAR(191) NOT NULL, + `business` VARCHAR(191) NOT NULL, + `employmentContractEN` VARCHAR(191) NOT NULL, + `employmentContractFR` VARCHAR(191) NOT NULL, + `startDateEN` VARCHAR(191) NOT NULL, + `startDateFR` VARCHAR(191) NOT NULL, + `endDateEN` VARCHAR(191) NOT NULL, + `endDateFR` VARCHAR(191) NOT NULL, + `month` INTEGER NOT NULL, + `typeEN` VARCHAR(191) NOT NULL, + `typeFR` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Skill` ADD CONSTRAINT `Skill_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `SkillCategory`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ProjectSkill` ADD CONSTRAINT `ProjectSkill_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `ProjectSkill` ADD CONSTRAINT `ProjectSkill_skillId_fkey` FOREIGN KEY (`skillId`) REFERENCES `Skill`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/src/prisma/migrations/20250610120631_add_user_model/migration.sql b/backend/src/prisma/migrations/20250610120631_add_user_model/migration.sql new file mode 100644 index 00000000..5e639d41 --- /dev/null +++ b/backend/src/prisma/migrations/20250610120631_add_user_model/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `User` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `firstname` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NOT NULL, + `role` ENUM('admin', 'editor', 'view') NOT NULL DEFAULT 'view', + `isPasswordChange` BOOLEAN NOT NULL DEFAULT false, + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/src/prisma/migrations/20250610120712_add_user_model/migration.sql b/backend/src/prisma/migrations/20250610120712_add_user_model/migration.sql new file mode 100644 index 00000000..af5102c8 --- /dev/null +++ b/backend/src/prisma/migrations/20250610120712_add_user_model/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/backend/src/prisma/migrations/migration_lock.toml b/backend/src/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..592fc0b3 --- /dev/null +++ b/backend/src/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 (e.g., Git) +provider = "mysql" diff --git a/backend/src/prisma/schema.prisma b/backend/src/prisma/schema.prisma new file mode 100644 index 00000000..0ccec7c1 --- /dev/null +++ b/backend/src/prisma/schema.prisma @@ -0,0 +1,100 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model Project { + id Int @id @default(autoincrement()) + title String + descriptionEN String @db.Text + descriptionFR String @db.Text + typeDisplay String + github String? + contentDisplay String @db.Text + skills ProjectSkill[] +} + +model SkillCategory { + id Int @id @default(autoincrement()) + categoryEN String + categoryFR String + skills Skill[] +} + +model Skill { + id Int @id @default(autoincrement()) + name String + image String + categoryId Int + category SkillCategory @relation(fields: [categoryId], references: [id]) + projects ProjectSkill[] +} + +model ProjectSkill { + projectId Int + skillId Int + project Project @relation(fields: [projectId], references: [id]) + skill Skill @relation(fields: [skillId], references: [id]) + + @@id([projectId, skillId]) +} + +model Education { + id Int @id @default(autoincrement()) + titleFR String + titleEN String + diplomaLevelEN String + diplomaLevelFR String + school String + location String + year Int + startDateEN String + startDateFR String + endDateEN String + endDateFR String + month Int + typeEN String + typeFR String +} + +model Experience { + id Int @id @default(autoincrement()) + jobEN String + jobFR String + business String + employmentContractEN String + employmentContractFR String + startDateEN String + startDateFR String + endDateEN String + endDateFR String + month Int + typeEN String + typeFR String +} + +enum Role { + admin + editor + view +} + +model User { + id Int @id @default(autoincrement()) + firstname String + lastname String + email String @unique + password String + role Role @default(view) + isPasswordChange Boolean @default(false) +} \ No newline at end of file diff --git a/backend/src/prisma/scripts/clean.ts b/backend/src/prisma/scripts/clean.ts new file mode 100644 index 00000000..910f9459 --- /dev/null +++ b/backend/src/prisma/scripts/clean.ts @@ -0,0 +1,57 @@ +import { PrismaClient } from '@prisma/client'; +import readline from 'readline'; + +const prisma = new PrismaClient(); +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +const ask = (q: string) => new Promise(r => rl.question(q, r)); + +async function cleanDatabase() { + const answer = await ask("⚠️ This will PERMANENTLY delete ALL data and reset auto-increments from your database. Are you absolutely sure? (y/n): "); + if (answer.toLowerCase() !== 'y') { + console.log("❌ Database clean up cancelled."); + rl.close(); + return; + } + console.log("⏳ Cleaning database and resetting auto-increments..."); + + try { + + await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0;'); + + + console.log("Deleting data..."); + await prisma.projectSkill.deleteMany(); + await prisma.project.deleteMany(); + await prisma.skill.deleteMany(); + await prisma.skillCategory.deleteMany(); + await prisma.education.deleteMany(); + await prisma.experience.deleteMany(); + await prisma.user.deleteMany(); + console.log("Data deleted."); + + console.log("Resetting auto-increments..."); + await prisma.$executeRawUnsafe('ALTER TABLE `Project` AUTO_INCREMENT = 1;'); + await prisma.$executeRawUnsafe('ALTER TABLE `SkillCategory` AUTO_INCREMENT = 1;'); + await prisma.$executeRawUnsafe('ALTER TABLE `Skill` AUTO_INCREMENT = 1;'); + + + await prisma.$executeRawUnsafe('ALTER TABLE `Education` AUTO_INCREMENT = 1;'); + await prisma.$executeRawUnsafe('ALTER TABLE `Experience` AUTO_INCREMENT = 1;'); + await prisma.$executeRawUnsafe('ALTER TABLE `User` AUTO_INCREMENT = 1;'); + console.log("Auto-increments reset."); + + await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1;'); + + console.log("✅ Database cleaned and auto-increments reset successfully. All data has been deleted and IDs will restart from 1."); + } catch (error) { + console.error("❌ Error cleaning database:", error); + } finally { + rl.close(); + await prisma.$disconnect(); + } +} + +cleanDatabase().catch(e => { + console.error(e); + rl.close(); +}); \ No newline at end of file diff --git a/backend/src/prisma/scripts/seed.ts b/backend/src/prisma/scripts/seed.ts new file mode 100644 index 00000000..01ced691 --- /dev/null +++ b/backend/src/prisma/scripts/seed.ts @@ -0,0 +1,147 @@ +import { PrismaClient } from '@prisma/client'; +import readline from 'readline'; +import { projectsData } from '../seed/projectsData'; +import { skillsData } from '../seed/skillsData'; +import { experiencesData } from '../seed/experiencesData'; +import { educationsData } from '../seed/educationsData'; + +const prisma = new PrismaClient(); +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +const ask = (q: string) => new Promise(r => rl.question(q, r)); + +async function seed() { + const answer = await ask("⚠️ This will reset and seed your database. Are you sure? (y/n): "); + if (answer.toLowerCase() !== 'y') { + console.log("❌ Seed cancelled."); + rl.close(); + return; + } + console.log("⏳ Seeding database..."); + + // Vide les tables pivot et principales + await prisma.projectSkill.deleteMany(); + await prisma.project.deleteMany(); + await prisma.skill.deleteMany(); + await prisma.skillCategory.deleteMany(); + // Vide education et experience + await prisma.education.deleteMany(); + await prisma.experience.deleteMany(); + await prisma.user.deleteMany(); + + // 1) Seed des catégories et des skills déclarés + for (const cat of skillsData) { + const catRec = await prisma.skillCategory.create({ + data: { categoryEN: cat.categoryEN, categoryFR: cat.categoryFR } + }); + for (const sk of cat.skills) { + await prisma.skill.create({ + data: { + name: sk.name, + image: sk.image, + categoryId: catRec.id + } + }); + } + } + + // 2) Seed des projects et création des liens project–skill + for (const proj of projectsData) { + const projRec = await prisma.project.create({ + data: { + title: proj.title, + descriptionEN: proj.descriptionEN, + descriptionFR: proj.descriptionFR, + typeDisplay: proj.typeDisplay, + github: proj.github ?? null, + contentDisplay: proj.contentDisplay, + } + }); + + for (const sk of proj.skills) { + // Recherche de la skill par nom + let skillRec = await prisma.skill.findFirst({ + where: { name: sk.name } + }); + + if (!skillRec) { + // Création de la catégorie "Others" si nécessaire + let otherCat = await prisma.skillCategory.findFirst({ + where: { categoryEN: "Others" } + }); + if (!otherCat) { + otherCat = await prisma.skillCategory.create({ + data: { categoryEN: "Others", categoryFR: "Autres" } + }); + } + + // Création de la skill manquante + skillRec = await prisma.skill.create({ + data: { + name: sk.name, + image: sk.image, + categoryId: otherCat.id + } + }); + console.log(`ℹ️ Created missing skill: ${sk.name}`); + } + + // Création du lien project–skill + await prisma.projectSkill.create({ + data: { + projectId: projRec.id, + skillId: skillRec.id + } + }); + } + } + + // 3) Seed des educations + for (const edu of educationsData) { + await prisma.education.create({ + data: { + titleFR: edu.titleFR, + titleEN: edu.titleEN, + diplomaLevelEN: edu.diplomaLevelEN, + diplomaLevelFR: edu.diplomaLevelFR, + school: edu.school, + location: edu.location, + year: edu.year, + startDateEN: edu.startDateEN, + startDateFR: edu.startDateFR, + endDateEN: edu.endDateEN, + endDateFR: edu.endDateFR, + month: edu.month, + typeEN: edu.typeEN, + typeFR: edu.typeFR, + } + }); + } + + // 4) Seed des experiences + for (const exp of experiencesData) { + await prisma.experience.create({ + data: { + jobEN: exp.jobEN, + jobFR: exp.jobFR, + business: exp.business, + employmentContractEN: exp.employmentContractEN, + employmentContractFR: exp.employmentContractFR, + startDateEN: exp.startDateEN, + startDateFR: exp.startDateFR, + endDateEN: exp.endDateEN, + endDateFR: exp.endDateFR, + month: exp.month, + typeEN: exp.typeEN, + typeFR: exp.typeFR, + } + }); + } + + console.log("✅ Database seeded successfully."); + rl.close(); +} + +seed().catch(e => { + console.error(e); + rl.close(); +}); \ No newline at end of file diff --git a/frontend/src/Data/educationsData.tsx b/backend/src/prisma/seed/educationsData.ts similarity index 100% rename from frontend/src/Data/educationsData.tsx rename to backend/src/prisma/seed/educationsData.ts diff --git a/frontend/src/Data/experiencesData.tsx b/backend/src/prisma/seed/experiencesData.ts similarity index 100% rename from frontend/src/Data/experiencesData.tsx rename to backend/src/prisma/seed/experiencesData.ts diff --git a/frontend/src/Data/projectsData.tsx b/backend/src/prisma/seed/projectsData.ts similarity index 74% rename from frontend/src/Data/projectsData.tsx rename to backend/src/prisma/seed/projectsData.ts index ba8ff70c..82f93092 100644 --- a/frontend/src/Data/projectsData.tsx +++ b/backend/src/prisma/seed/projectsData.ts @@ -7,8 +7,8 @@ export const projectsData = [ descriptionFR: "Semantik est un outil SEO qui vous permet d'identifier les questions les plus posées par les internautes sur Google. En renseignant simplement votre thématique, vous obtenez en un clic une liste exhaustive de ces questions. Utilisez-les pour optimiser la sémantique de vos contenus et répondre aux besoins du SEO.", typeDisplay: "image", - github: null, - contentDisplay: "https://i.goopics.net/6iqbww.png", + github: undefined, + contentDisplay: "Semantik.png", skills: [ { name: "JavaScript", @@ -137,7 +137,7 @@ export const projectsData = [ "NotesApp est une application de prise de notes avec une authentification complète. Espace de administrateur pour gérer les utilisateur, Les utilisateurs peuvent se connecter, s'inscrire, consulter les notes, tout en bénéficiant de fonctionnalités de récupération et de réinitialisation du mot de passe.", typeDisplay: "image", github: "https://github.com/Alexandre78R/NotesApp", - contentDisplay: "https://i.goopics.net/3av36l.png", + contentDisplay: "NotesApp.png", skills: [ { name: "JavaScript", @@ -195,7 +195,7 @@ export const projectsData = [ "Tchat est une application de chat en temps réel où les utilisateurs peuvent s'envoyer des messages instantanément. Le frontend est développé en React, tandis que le backend utilise Express et WebSocket pour gérer la communication en temps réel.", typeDisplay: "image", github: "https://github.com/Alexandre78R/tchat", - contentDisplay: "https://i.goopics.net/r36pm9.png", + contentDisplay: "Tchat.png", skills: [ { name: "JavaScript", @@ -481,4 +481,172 @@ export const projectsData = [ }, ], }, + { + id: 10, + title: "Portfolio", + descriptionEN: + "My Portfolio – A personal project built to showcase my background, skills, and projects. Developed with React, Next.js, and TypeScript on the frontend, and Express, GraphQL, and Prisma on the backend. Clean design with Tailwind CSS, for a site that reflects who I am: simple, clear, and efficient.", + descriptionFR: + "Mon Portfolio – Un projet personnel qui me permet de présenter mon parcours, mes compétences et mes projets. Conçu avec React, Next.js et TypeScript côté frontend, et un backend en Express, GraphQL et Prisma. Une interface soignée avec Tailwind, pour un site à mon image : simple, clair et efficace.", + typeDisplay: "image", + github: "https://github.com/Alexandre78R/portfolio", + contentDisplay: "Portfolio.png", + skills: [ + { + name: "TypeScript", + image: + "https://img.shields.io/badge/-TypeScript-007ACC?style=flat-square&logo=typescript&logoColor=white", + }, + { + name: "Next.js", + image: + "https://img.shields.io/badge/-Next.js-000000?style=flat-square&logo=next.js&logoColor=white", + }, + { + name: "Redux", + image: + "https://img.shields.io/badge/-Redux-8C1EB2?style=flat-square&logo=redux&logoColor=white", + }, + { + name: "Tailwind CSS", + image: + "https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white", + }, + { + name: "Nodejs", + image: + "https://img.shields.io/badge/-Nodejs-44883e?style=flat-square&logo=Node.js&logoColor=white", + }, + { + name: "Express", + image: + "https://img.shields.io/badge/-Express-000000?style=flat-square&logoColor=white", + }, + { + name: "Apollo GraphQL", + image: + "https://img.shields.io/badge/-Apollo%20GraphQL-311C87?style=flat-square&logo=apollo-graphql&logoColor=white", + }, + { + name: "TypeGraphQL", + image: + "https://img.shields.io/badge/-TypeGraphQL-5149B8?style=flat-square&logo=graphql&logoColor=white", + }, + { + name: "MySQL", + image: + "https://img.shields.io/badge/-MySQL-F29111?style=flat-square&logo=MySQL&logoColor=white", + }, + { + name: "Prisma", + image: + "https://img.shields.io/badge/-Prisma-000000?style=flat-square&logo=Prisma&logoColor=white", + }, + { + name: "Docker", + image: + "https://img.shields.io/badge/-Docker-0db7ed?style=flat-square&logo=docker&logoColor=white", + }, + { + name: "Github Action", + image: + "https://img.shields.io/badge/-Github%20Action-000000?style=flat-square&logo=github$&logoColor=white", + }, + { + name: "Caddy", + image: + "https://img.shields.io/badge/-Caddy-26CFA7?style=flat-square&logo=caddy&logoColor=white", + }, + { + name: "Nginx", + image: + "https://img.shields.io/badge/-Nginx-1EA718?style=flat-square&logo=nginx&logoColor=white", + }, + { + name: "Jest", + image: + "https://img.shields.io/badge/-Jest-FC958A?style=flat-square&logo=jest&logoColor=white", + }, + { + name: "Figma", + image: + "https://img.shields.io/badge/-Figma-a259ff?style=flat-square&logo=Figma&logoColor=white", + }, + { + name: "Postman", + image: + "https://img.shields.io/badge/-Postman-F66526?style=flat-square&logo=Postman&logoColor=white", + }, + { + name: "Git", + image: + "https://img.shields.io/badge/-Git-F14E32?style=flat-square&logo=git&logoColor=white", + }, + ], + }, + { + id: 11, + title: "DailyLog", + descriptionEN: + "DailyLog is a personal journaling application that allows users to record their daily moods and view related statistics. The main goal of this project is to practice and deepen Angular skills, while building a simple backend using Express, Prisma, and MySQL to manage the data.", + descriptionFR: + "DailyLog est une application de journal de bord personnel permettant d’enregistrer ses humeurs quotidiennes et de visualiser des statistiques associées. Le but principal de ce projet est de pratiquer et approfondir les compétences en Angular, tout en développant un backend simple avec Express, Prisma et MySQL pour gérer les données.", + typeDisplay: "video", + github: "https://github.com/Alexandre78R/Project-DailyLog-Angular", + contentDisplay: "dailyLog.mp4", + skills: [ + { + name: "TypeScript", + image: + "https://img.shields.io/badge/-TypeScript-007ACC?style=flat-square&logo=typescript&logoColor=white", + }, + { + name: "Angular", + image: "https://img.shields.io/badge/-Angular-DD0031?style=flat-square&logo=angular&logoColor=white" + }, + { + name: "RxJS", + image: "https://img.shields.io/badge/-RxJS-B7178C?style=flat-square&logo=reactivex&logoColor=white" + }, + { + name: "Tailwind CSS", + image: + "https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white", + }, + { + name: "Express", + image: + "https://img.shields.io/badge/-Express-000000?style=flat-square&logoColor=white", + }, + { + name: "Nodejs", + image: + "https://img.shields.io/badge/-Nodejs-44883e?style=flat-square&logo=Node.js&logoColor=white", + }, + { + name: "MySQL", + image: + "https://img.shields.io/badge/-MySQL-F29111?style=flat-square&logo=MySQL&logoColor=white", + }, + { + name: "Prisma", + image: + "https://img.shields.io/badge/-Prisma-000000?style=flat-square&logo=Prisma&logoColor=white", + }, + { + name: "Karma", + image: "https://img.shields.io/badge/-Karma-3A3A3A?style=flat-square&logo=karma&logoColor=white" + }, + { + name: "Postman", + image: + "https://img.shields.io/badge/-Postman-F66526?style=flat-square&logo=Postman&logoColor=white", + }, + { + name: "Git", + image: + "https://img.shields.io/badge/-Git-F14E32?style=flat-square&logo=git&logoColor=white", + }, + ], + }, ]; diff --git a/frontend/src/Data/skillsData.tsx b/backend/src/prisma/seed/skillsData.ts similarity index 71% rename from frontend/src/Data/skillsData.tsx rename to backend/src/prisma/seed/skillsData.ts index 16f2fd2b..68bf7199 100644 --- a/frontend/src/Data/skillsData.tsx +++ b/backend/src/prisma/seed/skillsData.ts @@ -118,65 +118,31 @@ export const skillsData = [ ], }, { - id: 5, - categoryEN: "Frontend Development", - categoryFR: "Développement Frontend", - skills: [ - { - name: "React", - image: - "https://img.shields.io/badge/-React-45b8d8?style=flat-square&logo=react&logoColor=white", - }, - { - name: "Next.js", - image: - "https://img.shields.io/badge/-Next.js-000000?style=flat-square&logo=next.js&logoColor=white", - }, - { - name: "Redux", - image: - "https://img.shields.io/badge/-Redux-8C1EB2?style=flat-square&logo=redux&logoColor=white", - }, - { - name: "Tailwind CSS", - image: - "https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white", - }, - { - name: "MUI", - image: - "https://img.shields.io/badge/-MUI-167FDC?style=flat-square&logo=mui&logoColor=white", - }, - { - name: "Chakra UI", - image: - "https://img.shields.io/badge/-Chakra%20UI-36C5CA?style=flat-square&logo=chakra-ui&logoColor=white", - }, - { - name: "Bootstrap", - image: - "https://img.shields.io/badge/-Bootstrap-a259ff?style=flat-square&logo=bootstrap&logoColor=white", - }, - { - name: "SASS", - image: - "https://img.shields.io/badge/-SASS-CC69BF?style=flat-square&logo=sass&logoColor=white", - }, - { - name: "HTML5", - image: - "https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white", - }, - { - name: "CSS3", - image: - "https://img.shields.io/badge/-CSS3-264de4?style=flat-square&logo=css3&logoColor=white", - }, - ], - color: "bg-yellow-500", + "id": 5, + "categoryEN": "Frontend Frameworks & Libraries", + "categoryFR": "Frameworks & Bibliothèques Frontend", + "skills": [ + { "name": "React", "image": "https://img.shields.io/badge/-React-45b8d8?style=flat-square&logo=react&logoColor=white" }, + { "name": "Next.js", "image": "https://img.shields.io/badge/-Next.js-000000?style=flat-square&logo=next.js&logoColor=white" }, + { "name": "Redux", "image": "https://img.shields.io/badge/-Redux-8C1EB2?style=flat-square&logo=redux&logoColor=white" }, + { "name": "Angular", "image": "https://img.shields.io/badge/-Angular-DD0031?style=flat-square&logo=angular&logoColor=white" }, + { "name": "RxJS", "image": "https://img.shields.io/badge/-RxJS-B7178C?style=flat-square&logo=reactivex&logoColor=white" } + ] + }, + { + "id": 6, + "categoryEN": "UI Frameworks & Styling Tools", + "categoryFR": "Frameworks UI & Outils de Style", + "skills": [ + { "name": "Tailwind CSS", "image": "https://img.shields.io/badge/-Tailwind%20CSS-24CDCD?style=flat-square&logo=tailwindcss&logoColor=white" }, + { "name": "MUI", "image": "https://img.shields.io/badge/-MUI-167FDC?style=flat-square&logo=mui&logoColor=white" }, + { "name": "Chakra UI", "image": "https://img.shields.io/badge/-Chakra%20UI-36C5CA?style=flat-square&logo=chakra-ui&logoColor=white" }, + { "name": "Bootstrap", "image": "https://img.shields.io/badge/-Bootstrap-a259ff?style=flat-square&logo=bootstrap&logoColor=white" }, + { "name": "SASS", "image": "https://img.shields.io/badge/-SASS-CC69BF?style=flat-square&logo=sass&logoColor=white" } + ] }, { - id: 6, + id: 7, categoryEN: "Testing & Web Scraping", categoryFR: "Tests & Scraping Web", skills: [ @@ -185,6 +151,10 @@ export const skillsData = [ image: "https://img.shields.io/badge/-Jest-FC958A?style=flat-square&logo=jest&logoColor=white", }, + { + name: "Karma", + image: "https://img.shields.io/badge/-Karma-3A3A3A?style=flat-square&logo=karma&logoColor=white" + }, { name: "Cypress", image: @@ -198,7 +168,7 @@ export const skillsData = [ ], }, { - id: 7, + id: 8, categoryEN: "Mobile App Development", categoryFR: "Développement d'Applications Mobiles", skills: [ @@ -220,7 +190,7 @@ export const skillsData = [ ], }, { - id: 8, + id: 9, categoryEN: "Tools", categoryFR: "Outils", skills: [ @@ -241,4 +211,13 @@ export const skillsData = [ }, ], }, + { + "id": 10, + "categoryEN": "Markup & Styling Languages", + "categoryFR": "Langages de Marquage & de Style", + "skills": [ + { "name": "HTML5", "image": "https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white" }, + { "name": "CSS3", "image": "https://img.shields.io/badge/-CSS3-264de4?style=flat-square&logo=css3&logoColor=white" } + ] + } ]; diff --git a/backend/src/regex.ts b/backend/src/regex.ts index a4ac7873..1c163a58 100644 --- a/backend/src/regex.ts +++ b/backend/src/regex.ts @@ -1,4 +1,5 @@ export const emailRegex = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/; +export const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\[\]{}|;:,.<>?]).{9,}$/; //Check regex diff --git a/backend/src/resolvers/admin.resolver.ts b/backend/src/resolvers/admin.resolver.ts new file mode 100644 index 00000000..fa3dc622 --- /dev/null +++ b/backend/src/resolvers/admin.resolver.ts @@ -0,0 +1,246 @@ +import { Resolver, Mutation, Authorized, Query, Arg } from "type-graphql"; +import { UserRole } from "../entities/user.entity"; +import { exec } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as util from "util"; +import { BackupFileInfo, BackupFilesResponse, BackupResponse, GlobalStats, GlobalStatsResponse, Response } from "../entities/response.types"; +import { PrismaClient } from "@prisma/client"; +import { promises as fsPromises } from 'fs'; + +const execPromise = util.promisify(exec); +export const dataFolderPath = path.join(__dirname, "../data"); + +@Resolver() +export class AdminResolver { + + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + private backupDir = path.resolve(__dirname, '../data') + + /** + * Génère une sauvegarde de la base de données MySQL en utilisant mysqldump. + * La sauvegarde est enregistrée dans le dossier 'data' à la racine du projet + * avec un nom de fichier incluant un horodatage pour éviter d'écraser les précédentes. + * @returns Un message indiquant le succès ou l'échec de l'opération. + */ + @Authorized([UserRole.admin]) + @Mutation(() => BackupResponse) + async generateDatabaseBackup(): Promise { + const dataFolderPath = path.join(__dirname, "../data"); + + try { + if (!fs.existsSync(dataFolderPath)) { + fs.mkdirSync(dataFolderPath, { recursive: true }); + console.log(`Dossier 'data' créé à: ${dataFolderPath}`); + } + + const now = new Date(); + + // Format timestamp simple : YYYYMMDD_HHmmss + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + const formattedTimestamp = `${year}${month}${day}_${hours}${minutes}${seconds}`; + const backupFileName = `bdd_${formattedTimestamp}.sql`; + const backupFilePath = path.join(dataFolderPath, backupFileName); + + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + throw new Error("DATABASE_URL non défini dans les variables d'environnement."); + } + + const urlParts = new URL(dbUrl); + const user = urlParts.username; + const password = urlParts.password; + const host = urlParts.hostname; + const port = urlParts.port || "3306"; + const database = urlParts.pathname.substring(1); + + const command = `mysqldump -h ${host} -P ${port} -u ${user} -p"${password}" ${database} > ${backupFilePath}`; + + console.log(`Début de la sauvegarde de la base de données vers: ${backupFilePath}`); + + const { stdout, stderr } = await execPromise(command); + + if (stderr) { + console.error(`Erreur mysqldump (stderr): ${stderr}`); + return { + code: 500, + message: `Sauvegarde terminée avec des erreurs: ${stderr}`, + path: backupFilePath, + }; + } + if (stdout) { + console.log(`mysqldump (stdout): ${stdout}`); + } + + return { + code: 200, + message: `Database backup generated successfully at ${backupFilePath}`, + path: backupFilePath, + }; + + } catch (error) { + console.error("Erreur lors de la génération de la sauvegarde de la base de données:", error); + return { + code: 500, + message: `Erreur lors de la génération de la sauvegarde de la base de données: ${error instanceof Error ? error.message : "Erreur inconnue"}`, + path: "", + }; + } + } + + /** + * Récupère des statistiques globales sur le contenu de la base de données. + * Seuls les administrateurs peuvent y accéder. + */ + @Authorized([UserRole.admin]) + @Query(() => GlobalStatsResponse) + async getGlobalStats(): Promise { + try { + + const totalUsers = await this.db.user.count(); + const totalProjects = await this.db.project.count(); + const totalSkills = await this.db.skill.count(); + const totalEducations = await this.db.education.count(); + const totalExperiences = await this.db.experience.count(); + + const usersByRole = await this.db.user.groupBy({ + by: ['role'], + _count: { + id: true, + }, + }); + + const usersByRoleMap = usersByRole.reduce((acc, item) => { + acc[item.role] = item._count.id; + return acc; + }, {} as Record); + + const stats: GlobalStats = { + totalUsers, + totalProjects, + totalSkills, + totalEducations, + totalExperiences, + usersByRoleAdmin: usersByRoleMap[UserRole.admin] || 0, + usersByRoleEditor: usersByRoleMap[UserRole.editor] || 0, + usersByRoleView: usersByRoleMap[UserRole.view] || 0, + }; + + return { + code: 200, + message: "Global statistics fetched successfully.", + stats, + }; + } catch (error) { + console.error("Error fetching global stats:", error); + return { code: 500, message: "Failed to fetch global statistics." }; + } + } + + /** + * Liste les fichiers de sauvegarde présents dans le dossier 'data'. + * Seuls les administrateurs peuvent y accéder. + * @return Un objet contenant le code de réponse, un message et la liste des fichiers de sauvegarde. + * */ + + @Authorized([UserRole.admin]) + @Query(() => BackupFilesResponse) + async listBackupFiles() { + try { + const files = await fsPromises.readdir(this.backupDir); + const backupFiles = []; + + for (const file of files) { + // console.log(files) + try { + const filePath = path.join(this.backupDir, file); + const stats = await fsPromises.stat(filePath); + + if (stats.isFile()) { + backupFiles.push({ + fileName: file, + sizeBytes: stats.size, + modifiedAt: stats.mtime, + createdAt: stats.ctime, + }); + } + } catch (err) { + console.warn(`Could not get stats for file ${file}:`, err); + // Continue malgré l'erreur + } + } + + return { + code: 200, + message: `Backup files listed successfully (${backupFiles.length} files)`, + files: backupFiles, + }; + } catch (err: any) { + if (err.code === 'ENOENT') { + // Dossier introuvable : renvoyer succès avec liste vide + return { + code: 200, + message: 'Backup folder does not exist, no files found', + files: [], + }; + } + console.error('Error listing backup files:', err); + return { + code: 500, + message: `Failed to list backup files: ${err.message}`, + }; + } + } + + /** + * Supprime un fichier de sauvegarde spécifique du dossier 'data'. + * Seuls les administrateurs peuvent effectuer cette action. + * @param fileName Le nom du fichier de sauvegarde à supprimer. + * @returns Un message indiquant le succès ou l'échec de l'opération. + */ + @Authorized([UserRole.admin]) + @Mutation(() => Response) + async deleteBackupFile( + @Arg("fileName") fileName: string + ): Promise { + const filePathToDelete = path.join(dataFolderPath, fileName); + + try { + const normalizedFilePath = path.normalize(filePathToDelete); + if (!normalizedFilePath.startsWith(dataFolderPath + path.sep)) { + return { + code: 400, + message: "Invalid file path. Cannot delete files outside the backup directory.", + }; + } + + if (!fs.existsSync(filePathToDelete)) { + return { + code: 404, + message: `Backup file '${fileName}' not found.`, + }; + } + + await fs.promises.unlink(filePathToDelete); + + return { + code: 200, + message: `Backup file '${fileName}' deleted successfully.`, + }; + + } catch (error) { + console.error(`Error deleting backup file '${fileName}':`, error); + return { + code: 500, + message: `Failed to delete backup file '${fileName}': ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + } +} \ No newline at end of file diff --git a/backend/src/resolvers/captcha.resolver.ts b/backend/src/resolvers/captcha.resolver.ts index cf45e21e..63b99bcf 100644 --- a/backend/src/resolvers/captcha.resolver.ts +++ b/backend/src/resolvers/captcha.resolver.ts @@ -19,23 +19,16 @@ import { ValidationResponse, ChallengeTypeTranslation, } from '../types/captcha.types'; -import { checkApiKey } from '../lib/checkApiKey'; - @Resolver() export class CaptchaResolver { @Query(() => CaptchaResponse) async generateCaptcha(@Ctx() context: MyContext): Promise { - - if (!context.apiKey) - throw new Error('Unauthorized TOKEN API'); - - await checkApiKey(context.apiKey); const id = uuidv4(); - const imagesDir = path.join(__dirname, '..', 'images'); + const imagesDir = path.join(__dirname, '..', 'images/captcha'); const files = fs.readdirSync(imagesDir); @@ -110,11 +103,6 @@ export class CaptchaResolver { @Ctx() context: MyContext ): Promise { - if (!context.apiKey) - throw new Error('Unauthorized TOKEN API'); - - await checkApiKey(context.apiKey); - checkExpiredCaptcha(idCaptcha); if (!captchaMap[idCaptcha]) @@ -149,11 +137,6 @@ export class CaptchaResolver { @Mutation(() => Boolean) async clearCaptcha(@Arg('idCaptcha') idCaptcha: string, @Ctx() context: MyContext): Promise { - if (!context.apiKey) - throw new Error('Unauthorized TOKEN API'); - - await checkApiKey(context.apiKey); - if (!captchaMap[idCaptcha]) { return true; } diff --git a/backend/src/resolvers/contact.resolver.ts b/backend/src/resolvers/contact.resolver.ts index 264929e2..8d2616ad 100644 --- a/backend/src/resolvers/contact.resolver.ts +++ b/backend/src/resolvers/contact.resolver.ts @@ -1,48 +1,32 @@ import { Arg, - Authorized, Ctx, - Float, Mutation, - Query, Resolver, } from "type-graphql"; import { - ContactFrom, - ContactResponse + ContactFrom, } from "../types/contact.types" import { sendEmail } from "../mail/mail.service"; import { MessageType} from "../types/message.types"; import { structureMessageMeTEXT, structureMessageMeHTML } from "../mail/structureMail.service"; import { MyContext } from ".."; -import { checkApiKey } from "../lib/checkApiKey"; import { checkRegex, emailRegex } from "../regex"; @Resolver() export class ContactResolver { - @Query(() => String) - async contact(@Ctx() context: MyContext): Promise { - console.log(context) - return "ok"; - } - @Mutation(() => MessageType) async sendContact(@Arg("data", () => ContactFrom) data: ContactFrom, @Ctx() context: MyContext): Promise { - - // if (!context.apiKey) - // throw new Error('Unauthorized TOKEN API'); - - // await checkApiKey(context.apiKey); if (!checkRegex(emailRegex, data.email)) throw new Error("Invaid format email."); const messageFinalMETEXT = await structureMessageMeTEXT(data); const messageFinalMEHTML = await structureMessageMeHTML(data); - const resultSendEmailME = await sendEmail(data?.email, data?.object, messageFinalMETEXT, messageFinalMEHTML); + const resultSendEmailME = await sendEmail(data?.email, data?.object, messageFinalMETEXT, messageFinalMEHTML, true); console.log("resutsSendEmail", resultSendEmailME) return resultSendEmailME; diff --git a/backend/src/resolvers/education.resolver.ts b/backend/src/resolvers/education.resolver.ts new file mode 100644 index 00000000..bbf81dc6 --- /dev/null +++ b/backend/src/resolvers/education.resolver.ts @@ -0,0 +1,134 @@ +import { Resolver, Query, Int, Arg, Mutation, Authorized, Ctx } from "type-graphql"; +import { Education } from "../entities/education.entity"; +import { PrismaClient } from "@prisma/client"; +import { EducationResponse, EducationsResponse } from "../entities/response.types"; +import { CreateEducationInput, UpdateEducationInput } from "../entities/inputs/education.input"; +import { UserRole } from "../entities/user.entity"; +import { MyContext } from ".."; + +@Resolver(() => Education) +export class EducationResolver { + + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + @Query(() => EducationsResponse) + async educationList(): Promise { + try { + const list = await this.db.education.findMany(); + return { code: 200, message: "Educations fetched", educations: list }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error fetching educations" }; + } + } + + @Query(() => EducationResponse) + async educationById( + @Arg("id", () => Int) id: number + ): Promise { + try { + const edu = await this.db.education.findUnique({ where: { id } }); + if (!edu) return { code: 404, message: "Education not found" }; + return { code: 200, message: "Education fetched", education: edu }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error fetching education" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => EducationResponse) + async createEducation( + @Arg("data") data: CreateEducationInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const edu = await this.db.education.create({ data }); + return { code: 200, message: "Education created", education: edu }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error creating education" }; + } + } + + @Authorized([UserRole.admin, UserRole.editor]) + @Mutation(() => EducationResponse) + async updateEducation( + @Arg("data") data: UpdateEducationInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + const authorizedRoles = [UserRole.admin, UserRole.editor]; + + if (!authorizedRoles.includes(ctx.user.role)) { + return { code: 403, message: "Access denied. Admin or Editor role required." }; + } + + const existing = await this.db.education.findUnique({ where: { id: data.id } }); + if (!existing) return { code: 404, message: "Education not found" }; + const up = await this.db.education.update({ + where: { id: data.id }, + data: { + titleFR: data.titleFR ?? existing.titleFR, + titleEN: data.titleEN ?? existing.titleEN, + diplomaLevelEN: data.diplomaLevelEN ?? existing.diplomaLevelEN, + diplomaLevelFR: data.diplomaLevelFR ?? existing.diplomaLevelFR, + school: data.school ?? existing.school, + location: data.location ?? existing.location, + year: data.year ?? existing.year, + startDateEN: data.startDateEN ?? existing.startDateEN, + startDateFR: data.startDateFR ?? existing.startDateFR, + endDateEN: data.endDateEN ?? existing.endDateEN, + endDateFR: data.endDateFR ?? existing.endDateFR, + month: data.month ?? existing.month, + typeEN: data.typeEN ?? existing.typeEN, + typeFR: data.typeFR ?? existing.typeFR, + }, + }); + return { code: 200, message: "Education updated", education: up }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error updating education" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => EducationResponse) + async deleteEducation( + @Arg("id", () => Int) id: number, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const existing = await this.db.education.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Education not found" }; + await this.db.education.delete({ where: { id } }); + return { code: 200, message: "Education deleted" }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error deleting education" }; + } + } +} \ No newline at end of file diff --git a/backend/src/resolvers/experience.resolver.ts b/backend/src/resolvers/experience.resolver.ts new file mode 100644 index 00000000..85ad491d --- /dev/null +++ b/backend/src/resolvers/experience.resolver.ts @@ -0,0 +1,131 @@ +import { Resolver, Query, Arg, Int, Mutation, Ctx, Authorized } from "type-graphql"; +import { Experience } from "../entities/experience.entity"; +import { ExperienceResponse, ExperiencesResponse } from "../entities/response.types"; +import { CreateExperienceInput, UpdateExperienceInput } from "../entities/inputs/experience.input"; +import { UserRole } from "../entities/user.entity"; +import { MyContext } from ".."; +import { PrismaClient } from "@prisma/client"; + +@Resolver(() => Experience) +export class ExperienceResolver { + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + @Query(() => ExperiencesResponse) + async experienceList(): Promise { + try { + const list = await this.db.experience.findMany(); + return { code: 200, message: "Experiences fetched", experiences: list }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error fetching experiences" }; + } + } + + @Query(() => ExperienceResponse) + async experienceById( + @Arg("id", () => Int) id: number + ): Promise { + try { + const exp = await this.db.experience.findUnique({ where: { id } }); + if (!exp) return { code: 404, message: "Experience not found" }; + return { code: 200, message: "Experience fetched", experience: exp }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error fetching experience" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => ExperienceResponse) + async createExperience( + @Arg("data") data: CreateExperienceInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const addExperience = await this.db.experience.create({ data }); + return { code: 200, message: "Experience created", experience: addExperience }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error creating experience" }; + } + } + + @Authorized([UserRole.admin, UserRole.editor]) + @Mutation(() => ExperienceResponse) + async updateExperience( + @Arg("data") data: UpdateExperienceInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + const authorizedRoles = [UserRole.admin, UserRole.editor]; + + if (!authorizedRoles.includes(ctx.user.role)) { + return { code: 403, message: "Access denied. Admin or Editor role required." }; + } + + const existing = await this.db.experience.findUnique({ where: { id: data.id } }); + if (!existing) return { code: 404, message: "Experience not found" }; + const up = await this.db.experience.update({ + where: { id: data.id }, + data: { + jobEN: data.jobEN ?? existing.jobEN, + jobFR: data.jobFR ?? existing.jobFR, + business: data.business ?? existing.business, + employmentContractEN: data.employmentContractEN ?? existing.employmentContractEN, + employmentContractFR: data.employmentContractFR ?? existing.employmentContractFR, + startDateEN: data.startDateEN ?? existing.startDateEN, + startDateFR: data.startDateFR ?? existing.startDateFR, + endDateEN: data.endDateEN ?? existing.endDateEN, + endDateFR: data.endDateFR ?? existing.endDateFR, + month: data.month ?? existing.month, + typeEN: data.typeEN ?? existing.typeEN, + typeFR: data.typeFR ?? existing.typeFR, + }, + }); + return { code: 200, message: "Experience updated", experience: up }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error updating experience" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => ExperienceResponse) + async deleteExperience( + @Arg("id", () => Int) id: number, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const existing = await this.db.experience.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Experience not found" }; + await this.db.experience.delete({ where: { id } }); + return { code: 200, message: "Experience deleted" }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error deleting experience" }; + } + } +} \ No newline at end of file diff --git a/backend/src/resolvers/generateImage.resolver.ts b/backend/src/resolvers/generateImage.resolver.ts deleted file mode 100644 index 5909a7d0..00000000 --- a/backend/src/resolvers/generateImage.resolver.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - Arg, - Authorized, - Float, - Mutation, - Query, - Resolver, -} from "type-graphql"; -import { createWriteStream } from "fs"; -import { v4 as uuidv4 } from "uuid"; -import multer from "multer"; -import path from "path"; -import fs from "fs"; - -import { - GenerateImageFrom, - GenerateImageResponse -} from '../types/generateImage.types' - -import { createCanvas, loadImage } from 'canvas'; - -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, './uploads/'); - }, - filename: function (req, file, cb) { - cb(null, uuidv4() + path.extname(file.originalname)); - } -}); - -const upload = multer({ storage: storage }); - -@Resolver() -export class GenerateImageResolver { - - // @Mutation(() => String) - // async generateImage( - // @Arg('data', () => GenerateImageFrom) data: GenerateImageFrom, - // @Arg('image', () => GraphQLUpload) image: FileUpload, - // ): Promise { - // const { createReadStream, filename, mimetype } = await image; - // const uploadPath = path.join(__dirname, `../public/images/${filename}`); - - // // Créer un flux de lecture et de sortie pour enregistrer l'image - // const stream = createReadStream(); - // const out = fs.createWriteStream(uploadPath); - // stream.pipe(out); - - // await new Promise((resolve, reject) => { - // stream.on('end', resolve); - // stream.on('error', reject); - // }); - - // // Créer l'image avec Canvas - // const width = 800; - // const height = 600; - // const canvas = createCanvas(width, height); - // const context = canvas.getContext('2d'); - - // context.fillStyle = data.backgroundColor; - // context.fillRect(0, 0, width, height); - - // if (data.logoUrl) { - // const logo = await loadImage(data.logoUrl); - // context.drawImage(logo, width / 2 - 50, height / 2 - 50, 100, 100); - // } - - // context.fillStyle = 'black'; - // context.font = '30px Arial'; - // context.fillText(data.text, 50, 550); - - // // Enregistrer le canvas comme fichier PNG - // const imageFileName = `image_${Date.now()}.png`; - // const imagePath = path.join(__dirname, `../public/images/${imageFileName}`); - // const outStream = fs.createWriteStream(imagePath); - // const streamPng = canvas.createPNGStream(); - // streamPng.pipe(outStream); - - // // Renvoyer l'URL de l'image enregistrée - // const imageUrl = `http://localhost:4000/images/${imageFileName}`; - // return imageUrl; - // } - -} \ No newline at end of file diff --git a/backend/src/resolvers/project.resolver.ts b/backend/src/resolvers/project.resolver.ts new file mode 100644 index 00000000..16f20df6 --- /dev/null +++ b/backend/src/resolvers/project.resolver.ts @@ -0,0 +1,245 @@ +import { Resolver, Query, Arg, Int, Mutation, Authorized, Ctx } from "type-graphql"; +import { Project } from "../entities/project.entity"; +import { CreateProjectInput, UpdateProjectInput } from "../entities/inputs/project.input"; +import { Response, ProjectResponse, ProjectsResponse } from "../entities/response.types"; +import { UserRole } from "../entities/user.entity"; +import { MyContext } from ".."; +import { PrismaClient } from "@prisma/client"; + +@Resolver(() => Project) +export class ProjectResolver { + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + @Query(() => ProjectsResponse) + async projectList(): Promise { + try { + const projects = await this.db.project.findMany({ + include: { + skills: { + include: { skill: true }, + }, + }, + orderBy: { id: "desc" }, + }); + + const mapped = projects.map((p) => ({ + id: p.id, + title: p.title, + descriptionFR: p.descriptionFR, + descriptionEN: p.descriptionEN, + typeDisplay: p.typeDisplay, + github: p.github ?? null, + contentDisplay: p.contentDisplay, + skills: p.skills.map((ps) => ({ + id: ps.skill.id, + name: ps.skill.name, + image: ps.skill.image, + categoryId: ps.skill.categoryId, + })), + })); + + return { code: 200, message: "Projects fetched successfully", projects: mapped }; + } catch (error) { + return { code: 500, message: "Internal server error" }; + } + } + + @Query(() => ProjectResponse) + async projectById( + @Arg("id", () => Int) id: number + ): Promise { + try { + const project = await this.db.project.findUnique({ + where: { id }, + include: { skills: { include: { skill: true } } }, + }); + + if (!project) return { code: 404, message: "Project not found" }; + + return { + code: 200, + message: "Project found", + project: { + ...project, + skills: project.skills.map((ps) => ({ + id: ps.skill.id, + name: ps.skill.name, + image: ps.skill.image, + categoryId: ps.skill.categoryId, + })), + }, + }; + } catch (error) { + return { code: 500, message: "Internal server error" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => ProjectResponse) + async createProject( + @Arg("data") data: CreateProjectInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const validSkills = await this.db.skill.findMany({ + where: { id: { in: data.skillIds } }, + }); + + if (validSkills.length !== data.skillIds.length) { + return { code: 400, message: "One or more skill IDs are invalid." }; + } + + const newProject = await this.db.project.create({ + data: { + title: data.title, + descriptionEN: data.descriptionEN, + descriptionFR: data.descriptionFR, + typeDisplay: data.typeDisplay, + github: data.github, + contentDisplay: data.contentDisplay, + skills: { + create: data.skillIds.map((skillId) => ({ + skill: { connect: { id: skillId } }, + })), + }, + }, + include: { + skills: { include: { skill: true } }, + }, + }); + + return { + code: 200, + message: "Project created successfully", + project: { + id: newProject.id, + title: newProject.title, + descriptionFR: newProject.descriptionFR, + descriptionEN: newProject.descriptionEN, + typeDisplay: newProject.typeDisplay, + github: newProject.github ?? null, + contentDisplay: newProject.contentDisplay, + skills: newProject.skills.map((ps) => ({ + id: ps.skill.id, + name: ps.skill.name, + image: ps.skill.image, + categoryId: ps.skill.categoryId, + })), + }, + }; + } catch (error) { + return { code: 500, message: "Internal server error" }; + } + } + + @Authorized([UserRole.admin, UserRole.editor]) + @Mutation(() => ProjectResponse) + async updateProject( + @Arg("data") data: UpdateProjectInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + const authorizedRoles = [UserRole.admin, UserRole.editor]; + + if (!authorizedRoles.includes(ctx.user.role)) { + return { code: 403, message: "Access denied. Admin or Editor role required." }; + } + + const { id, skillIds, ...rest } = data; + + const existingProject = await this.db.project.findUnique({ + where: { id }, + include: { skills: true }, + }); + + if (!existingProject) { + return { code: 404, message: "Project not found" }; + } + + if (skillIds) { + const validSkills = await this.db.skill.findMany({ + where: { id: { in: skillIds } }, + }); + + if (validSkills.length !== skillIds.length) { + return { code: 400, message: "One or more skill IDs are invalid." }; + } + + await this.db.projectSkill.deleteMany({ where: { projectId: id } }); + } + + const updatedProject = await this.db.project.update({ + where: { id }, + data: { + ...rest, + skills: skillIds + ? { create: skillIds.map((skillId) => ({ skill: { connect: { id: skillId } } })) } + : undefined, + }, + include: { + skills: { include: { skill: true } }, + }, + }); + + return { + code: 200, + message: "Project updated successfully", + project: { + ...updatedProject, + skills: updatedProject.skills.map((ps) => ({ + id: ps.skill.id, + name: ps.skill.name, + image: ps.skill.image, + categoryId: ps.skill.categoryId, + })), + }, + }; + } catch (error) { + return { code: 500, message: "Internal server error" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => Response) + async deleteProject( + @Arg("id", () => Int) id: number, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const existingProject = await this.db.project.findUnique({ where: { id } }); + if (!existingProject) { + return { code: 404, message: "Project not found" }; + } + + await this.db.projectSkill.deleteMany({ where: { projectId: id } }); + await this.db.project.delete({ where: { id } }); + + return { code: 200, message: "Project deleted successfully" }; + } catch (error) { + return { code: 500, message: "Internal server error" }; + } + } +} \ No newline at end of file diff --git a/backend/src/resolvers/skill.resolver.ts b/backend/src/resolvers/skill.resolver.ts new file mode 100644 index 00000000..d2d16a00 --- /dev/null +++ b/backend/src/resolvers/skill.resolver.ts @@ -0,0 +1,248 @@ +import { Resolver, Query, Mutation, Arg, Int, Authorized, Ctx } from "type-graphql"; +import { Skill } from "../entities/skill.entity"; +import { CreateCategoryInput, CreateSkillInput, UpdateCategoryInput, UpdateSkillInput } from "../entities/inputs/skill.input"; +import { SkillSubItem } from "../entities/skillSubItem.entity"; +import { CategoryResponse, SubItemResponse } from "../entities/response.types"; +import { UserRole } from "../entities/user.entity"; +import { MyContext } from ".."; +import { PrismaClient } from "@prisma/client"; + +@Resolver() +export class SkillResolver { + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + @Query(() => CategoryResponse) + async skillList(): Promise { + try { + const categories = await this.db.skillCategory.findMany({ + include: { skills: true }, + orderBy: { id: "asc" }, + }); + const dto = categories.map(cat => ({ + id: cat.id, + categoryEN: cat.categoryEN, + categoryFR: cat.categoryFR, + skills: cat.skills.map(s => ({ + id: s.id, + name: s.name, + image: s.image, + categoryId: s.categoryId, + })), + } as Skill)); + return { code: 200, message: "Categories fetched successfully", categories: dto }; + } catch (e) { + console.error(e); + return { code: 500, message: "Failed to fetch categories" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => CategoryResponse) + async createCategory( + @Arg("data") data: CreateCategoryInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + const category = await this.db.skillCategory.create({ + data: { categoryEN: data.categoryEN, categoryFR: data.categoryFR }, + }); + const dto: Skill = { + id: category.id, + categoryEN: category.categoryEN, + categoryFR: category.categoryFR, + skills: [], + }; + return { code: 200, message: "Category created successfully", categories: [dto] }; + } catch (e) { + console.error(e); + return { code: 500, message: "Failed to create category" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => SubItemResponse) + async createSkill( + @Arg("data") data: CreateSkillInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const category = await this.db.skillCategory.findUnique({ where: { id: data.categoryId } }); + if (!category) { + return { code: 400, message: "Category not found" }; + } + const subItem = await this.db.skill.create({ + data: { name: data.name, image: data.image, categoryId: data.categoryId }, + }); + const dto: SkillSubItem = { + id: subItem.id, + name: subItem.name, + image: subItem.image, + categoryId: subItem.categoryId, + }; + return { code: 200, message: "Skill created successfully", subItems: [dto] }; + } catch (e) { + console.error(e); + return { code: 500, message: "Failed to create skill" }; + } + } + + @Authorized([UserRole.admin, UserRole.editor]) + @Mutation(() => CategoryResponse) + async updateCategory( + @Arg("id", () => Int) id: number, + @Arg("data") data: UpdateCategoryInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + const authorizedRoles = [UserRole.admin, UserRole.editor]; + + if (!authorizedRoles.includes(ctx.user.role)) { + return { code: 403, message: "Access denied. Admin or Editor role required." }; + } + + const existing = await this.db.skillCategory.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Category not found" }; + const cat = await this.db.skillCategory.update({ + where: { id }, + data: { + categoryEN: data.categoryEN ?? existing.categoryEN, + categoryFR: data.categoryFR ?? existing.categoryFR, + }, + }); + const dto: Skill = { id: cat.id, categoryEN: cat.categoryEN, categoryFR: cat.categoryFR, skills: [] }; + return { code: 200, message: "Category updated", categories: [dto] }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error updating category" }; + } + } + + @Authorized([UserRole.admin, UserRole.editor]) + @Mutation(() => SubItemResponse) + async updateSkill( + @Arg("id", () => Int) id: number, + @Arg("data") data: UpdateSkillInput, + @Ctx() ctx: MyContext + ): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + const authorizedRoles = [UserRole.admin, UserRole.editor]; + + if (!authorizedRoles.includes(ctx.user.role)) { + return { code: 403, message: "Access denied. Admin or Editor role required." }; + } + + const existing = await this.db.skill.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Skill not found" }; + if (data.categoryId) { + const validCat = await this.db.skillCategory.findUnique({ where: { id: data.categoryId } }); + if (!validCat) return { code: 400, message: "Invalid category" }; + } + const subItem = await this.db.skill.update({ + where: { id }, + data: { + name: data.name ?? existing.name, + image: data.image ?? existing.image, + categoryId: data.categoryId ?? existing.categoryId, + }, + }); + const dto: SkillSubItem = { id: subItem.id, name: subItem.name, image: subItem.image, categoryId: subItem.categoryId }; + return { code: 200, message: "Skill updated", subItems: [dto] }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error updating skill" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => CategoryResponse) + async deleteCategory( + @Arg("id", () => Int) id: number, + @Ctx() ctx: MyContext + ): Promise { + try { + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const existing = await this.db.skillCategory.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Category not found" }; + + const skills = await this.db.skill.findMany({ where: { categoryId: id }, select: { id: true } }); + const skillIds = skills.map(s => s.id); + + if (skillIds.length) { + await this.db.projectSkill.deleteMany({ where: { skillId: { in: skillIds } } }); + } + + await this.db.skill.deleteMany({ where: { categoryId: id } }); + + await this.db.skillCategory.delete({ where: { id } }); + + return { code: 200, message: "Category and related skills deleted" }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error deleting category" }; + } + } + + @Authorized([UserRole.admin]) + @Mutation(() => SubItemResponse) + async deleteSkill( + @Arg("id", () => Int) id: number, + @Ctx() ctx: MyContext + ): Promise { + try { + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const existing = await this.db.skill.findUnique({ where: { id } }); + if (!existing) return { code: 404, message: "Skill not found" }; + + await this.db.projectSkill.deleteMany({ where: { skillId: id } }); + + await this.db.skill.delete({ where: { id } }); + + return { code: 200, message: "Skill and related sub-items deleted" }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error deleting skill" }; + } + } + +} diff --git a/backend/src/resolvers/user.resolver.ts b/backend/src/resolvers/user.resolver.ts index 49fdf79a..66805839 100644 --- a/backend/src/resolvers/user.resolver.ts +++ b/backend/src/resolvers/user.resolver.ts @@ -1 +1,227 @@ -// Resolver User \ No newline at end of file +import { Resolver, Query, Arg, Mutation, Ctx, Authorized } from "type-graphql"; +import { PrismaClient } from "@prisma/client"; +import { User } from "../entities/user.entity"; +import { UsersResponse, UserResponse, LoginResponse } from "../entities/response.types"; +import { UserRole } from "../entities/user.entity"; +import { generateSecurePassword } from "../lib/generateSecurePassword"; +import { sendEmail } from "../mail/mail.service"; +import { CreateUserInput, LoginInput } from "../entities/inputs/user.input"; +import argon2 from "argon2"; +import { structureMessageCreatedAccountHTML, structureMessageCreatedAccountTEXT } from "../mail/structureMail.service"; +import { Response } from "../entities/response.types"; +import { emailRegex, passwordRegex, checkRegex } from "../regex"; +import jwt from "jsonwebtoken"; +import { MyContext } from ".."; + +// const prisma = new PrismaClient(); + +@Resolver(() => User) +export class UserResolver { + constructor(private readonly db: PrismaClient = new PrismaClient()) {} + + @Authorized([UserRole.admin]) + @Query(() => UsersResponse) + async userList(@Ctx() ctx: MyContext): Promise { + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + if (ctx.user.role !== UserRole.admin) { + return { code: 403, message: "Access denied. Admin role required." }; + } + + const listUserFromPrisma = await this.db.user.findMany(); + + const users: User[] = listUserFromPrisma.map((u) => ({ + id: u.id, + firstname: u.firstname, + lastname: u.lastname, + email: u.email, + role: u.role as UserRole, + isPasswordChange: u.isPasswordChange, + })); + + return { code: 200, message: "Users fetched", users }; + } catch (error) { + console.error(error); + return { code: 500, message: "Error fetching users" }; + } + } + + @Mutation(() => UserResponse) + async registerUser(@Arg("data") data: CreateUserInput): Promise { + // console.log("📦 DB class used =", this.db === prisma ? "REAL" : "MOCK"); + try { + const existing = await this.db.user.findUnique({ where: { email: data.email } }); + if (existing) return { code: 409, message: "Email already exists" }; + + if (!checkRegex(emailRegex, data.email)) { + return { + code: 400, + message: + "You have entered an invalid email address.", + }; + } + + const plainPassword = generateSecurePassword(); + const hashedPassword = await argon2.hash(plainPassword); + + const createdUser = await this.db.user.create({ + data: { + firstname: data.firstname, + lastname: data.lastname, + email: data.email, + password: hashedPassword, + role: data.role as UserRole, + isPasswordChange: false, + }, + }); + + const subject = "Votre compte a été créé"; + + const messageFinalCreatedAccountTEXT = await structureMessageCreatedAccountTEXT(data.firstname, plainPassword); + const messageFinalCreatedAccountHTML = await structureMessageCreatedAccountHTML(data.firstname, plainPassword); + await sendEmail(data.email, subject, messageFinalCreatedAccountTEXT, messageFinalCreatedAccountHTML); + + return { + code: 201, + message: "User registered and email sent", + user: { + ...createdUser, + role: createdUser.role as UserRole, + }, + }; + } catch (error) { + console.error(error); + return { + code: 500, + message: error instanceof Error ? error.message : "Unexpected error", + }; + } + } + + @Mutation(() => Response) + async changePassword( + @Arg("email") email: string, + @Arg("newPassword") newPassword: string + ): Promise { + + if (!checkRegex(passwordRegex, newPassword)) { + return { + code: 400, + message: + "The password must contain at least 9 characters, with at least one uppercase letter, one lowercase letter, one number and one symbol.", + }; + } + + try { + + const user = await this.db.user.findUnique({ where: { email } }); + if (!user) { + return { + code: 404, + message: "User not found with this email.", + }; + } + + const hashedPassword = await argon2.hash(newPassword); + + await this.db.user.update({ + where: { email }, + data: { + password: hashedPassword, + isPasswordChange: true, + }, + }); + + return { + code: 200, + message: "Password updated successfully.", + }; + } catch (error) { + console.error("Erreur dans changePassword:", error); + return { + code: 500, + message: "Server error while updating password.", + }; + } + } + + @Mutation(() => LoginResponse) + async login(@Arg("data") { email, password }: LoginInput, @Ctx() ctx: MyContext): Promise { + try { + + const user = await this.db.user.findUnique({ where: { email } }); + if (!user) { + return { code: 401, message: "Invalid credentials (email or password incorrect)." }; + } + + const isPasswordValid = await argon2.verify(user.password, password); + if (!isPasswordValid) { + return { code: 401, message: "Invalid credentials (email or password incorrect)." }; + } + + const tokenPayload = { + userId: user.id, + }; + + if (!process.env.JWT_SECRET) { + return { code: 500, message: "Please check your JWT configuration !" }; + } + + const token = jwt.sign(tokenPayload, process.env.JWT_SECRET , { expiresIn: "7d" }); + + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + maxAge: 1000 * 60 * 60 * 24 * 7, + path: '/', + }; + + ctx.cookies.set("jwt", token, cookieOptions); + + return { + code: 200, + message: "Login successful.", + token: token, + }; + } catch (error) { + console.error("Erreur dans login:", error); + return { + code: 500, + message: error instanceof Error ? error.message : "Unexpected server error during login.", + }; + } + } + + @Mutation(() => Response) + async logout( + @Ctx() ctx: MyContext + ): Promise { + + try { + + if (!ctx.user) { + return { code: 401, message: "Authentication required." }; + } + + ctx.cookies.set("jwt", "", { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + expires: new Date(0), + path: '/', + }); + + ctx.user = null; + return { code: 200, message: "Logged out successfully." }; + + } catch (error) { + console.error("Erreur lors de la déconnexion:", error); + return { code: 500, message: "An error occurred during logout." }; + } + } +} \ No newline at end of file diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/types/contact.types.ts b/backend/src/types/contact.types.ts index 3fe2a7e2..1180c146 100644 --- a/backend/src/types/contact.types.ts +++ b/backend/src/types/contact.types.ts @@ -10,7 +10,6 @@ export class ContactFrom { @Field() message: string; - } @ObjectType() @@ -23,5 +22,4 @@ export class ContactResponse { @Field() message: string; - } \ No newline at end of file diff --git a/backend/src/types/graphql.ts b/backend/src/types/graphql.ts new file mode 100644 index 00000000..d9e2e384 --- /dev/null +++ b/backend/src/types/graphql.ts @@ -0,0 +1,556 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + DateTimeISO: { input: any; output: any; } +}; + +export type BackupFileInfo = { + __typename?: 'BackupFileInfo'; + createdAt: Scalars['DateTimeISO']['output']; + fileName: Scalars['String']['output']; + modifiedAt: Scalars['DateTimeISO']['output']; + sizeBytes: Scalars['Int']['output']; +}; + +export type BackupFilesResponse = { + __typename?: 'BackupFilesResponse'; + code: Scalars['Int']['output']; + files?: Maybe>; + message: Scalars['String']['output']; +}; + +export type BackupResponse = { + __typename?: 'BackupResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + path: Scalars['String']['output']; +}; + +export type CaptchaImage = { + __typename?: 'CaptchaImage'; + id: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; + url: Scalars['String']['output']; +}; + +export type CaptchaResponse = { + __typename?: 'CaptchaResponse'; + challengeType: Scalars['String']['output']; + challengeTypeTranslation: ChallengeTypeTranslation; + expirationTime: Scalars['Float']['output']; + id: Scalars['String']['output']; + images: Array; +}; + +export type CategoryResponse = { + __typename?: 'CategoryResponse'; + categories?: Maybe>; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; +}; + +export type ChallengeTypeTranslation = { + __typename?: 'ChallengeTypeTranslation'; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; +}; + +export type ContactFrom = { + email: Scalars['String']['input']; + message: Scalars['String']['input']; + object: Scalars['String']['input']; +}; + +export type CreateCategoryInput = { + categoryEN: Scalars['String']['input']; + categoryFR: Scalars['String']['input']; +}; + +export type CreateEducationInput = { + diplomaLevelEN: Scalars['String']['input']; + diplomaLevelFR: Scalars['String']['input']; + endDateEN: Scalars['String']['input']; + endDateFR: Scalars['String']['input']; + location: Scalars['String']['input']; + month: Scalars['Int']['input']; + school: Scalars['String']['input']; + startDateEN: Scalars['String']['input']; + startDateFR: Scalars['String']['input']; + titleEN: Scalars['String']['input']; + titleFR: Scalars['String']['input']; + typeEN: Scalars['String']['input']; + typeFR: Scalars['String']['input']; + year: Scalars['Int']['input']; +}; + +export type CreateExperienceInput = { + business: Scalars['String']['input']; + employmentContractEN: Scalars['String']['input']; + employmentContractFR: Scalars['String']['input']; + endDateEN: Scalars['String']['input']; + endDateFR: Scalars['String']['input']; + jobEN: Scalars['String']['input']; + jobFR: Scalars['String']['input']; + month: Scalars['Float']['input']; + startDateEN: Scalars['String']['input']; + startDateFR: Scalars['String']['input']; + typeEN: Scalars['String']['input']; + typeFR: Scalars['String']['input']; +}; + +export type CreateProjectInput = { + contentDisplay: Scalars['String']['input']; + descriptionEN: Scalars['String']['input']; + descriptionFR: Scalars['String']['input']; + github?: InputMaybe; + skillIds: Array; + title: Scalars['String']['input']; + typeDisplay: Scalars['String']['input']; +}; + +export type CreateSkillInput = { + categoryId: Scalars['Int']['input']; + image: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + +export type CreateUserInput = { + email: Scalars['String']['input']; + firstname: Scalars['String']['input']; + lastname: Scalars['String']['input']; + role: Scalars['String']['input']; +}; + +export type Education = { + __typename?: 'Education'; + diplomaLevelEN: Scalars['String']['output']; + diplomaLevelFR: Scalars['String']['output']; + endDateEN: Scalars['String']['output']; + endDateFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + location: Scalars['String']['output']; + month?: Maybe; + school: Scalars['String']['output']; + startDateEN: Scalars['String']['output']; + startDateFR: Scalars['String']['output']; + titleEN: Scalars['String']['output']; + titleFR: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; + year: Scalars['Int']['output']; +}; + +export type EducationResponse = { + __typename?: 'EducationResponse'; + code: Scalars['Int']['output']; + education?: Maybe; + message: Scalars['String']['output']; +}; + +export type EducationsResponse = { + __typename?: 'EducationsResponse'; + code: Scalars['Int']['output']; + educations?: Maybe>; + message: Scalars['String']['output']; +}; + +export type Experience = { + __typename?: 'Experience'; + business: Scalars['String']['output']; + employmentContractEN: Scalars['String']['output']; + employmentContractFR: Scalars['String']['output']; + endDateEN: Scalars['String']['output']; + endDateFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + jobEN: Scalars['String']['output']; + jobFR: Scalars['String']['output']; + month: Scalars['Float']['output']; + startDateEN: Scalars['String']['output']; + startDateFR: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; +}; + +export type ExperienceResponse = { + __typename?: 'ExperienceResponse'; + code: Scalars['Int']['output']; + experience?: Maybe; + message: Scalars['String']['output']; +}; + +export type ExperiencesResponse = { + __typename?: 'ExperiencesResponse'; + code: Scalars['Int']['output']; + experiences?: Maybe>; + message: Scalars['String']['output']; +}; + +export type GlobalStats = { + __typename?: 'GlobalStats'; + totalEducations: Scalars['Int']['output']; + totalExperiences: Scalars['Int']['output']; + totalProjects: Scalars['Int']['output']; + totalSkills: Scalars['Int']['output']; + totalUsers: Scalars['Int']['output']; + usersByRoleAdmin: Scalars['Int']['output']; + usersByRoleEditor: Scalars['Int']['output']; + usersByRoleView: Scalars['Int']['output']; +}; + +export type GlobalStatsResponse = { + __typename?: 'GlobalStatsResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + stats?: Maybe; +}; + +export type LoginInput = { + email: Scalars['String']['input']; + password: Scalars['String']['input']; +}; + +export type LoginResponse = { + __typename?: 'LoginResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + token?: Maybe; +}; + +export type MessageType = { + __typename?: 'MessageType'; + label: Scalars['String']['output']; + message: Scalars['String']['output']; + status: Scalars['Boolean']['output']; +}; + +export type Mutation = { + __typename?: 'Mutation'; + changePassword: Response; + clearCaptcha: Scalars['Boolean']['output']; + createCategory: CategoryResponse; + createEducation: EducationResponse; + createExperience: ExperienceResponse; + createProject: ProjectResponse; + createSkill: SubItemResponse; + deleteBackupFile: Response; + deleteCategory: CategoryResponse; + deleteEducation: EducationResponse; + deleteExperience: ExperienceResponse; + deleteProject: Response; + deleteSkill: SubItemResponse; + generateDatabaseBackup: BackupResponse; + login: LoginResponse; + logout: Response; + registerUser: UserResponse; + sendContact: MessageType; + updateCategory: CategoryResponse; + updateEducation: EducationResponse; + updateExperience: ExperienceResponse; + updateProject: ProjectResponse; + updateSkill: SubItemResponse; + validateCaptcha: ValidationResponse; +}; + + +export type MutationChangePasswordArgs = { + email: Scalars['String']['input']; + newPassword: Scalars['String']['input']; +}; + + +export type MutationClearCaptchaArgs = { + idCaptcha: Scalars['String']['input']; +}; + + +export type MutationCreateCategoryArgs = { + data: CreateCategoryInput; +}; + + +export type MutationCreateEducationArgs = { + data: CreateEducationInput; +}; + + +export type MutationCreateExperienceArgs = { + data: CreateExperienceInput; +}; + + +export type MutationCreateProjectArgs = { + data: CreateProjectInput; +}; + + +export type MutationCreateSkillArgs = { + data: CreateSkillInput; +}; + + +export type MutationDeleteBackupFileArgs = { + fileName: Scalars['String']['input']; +}; + + +export type MutationDeleteCategoryArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteEducationArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteExperienceArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteProjectArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteSkillArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationLoginArgs = { + data: LoginInput; +}; + + +export type MutationRegisterUserArgs = { + data: CreateUserInput; +}; + + +export type MutationSendContactArgs = { + data: ContactFrom; +}; + + +export type MutationUpdateCategoryArgs = { + data: UpdateCategoryInput; + id: Scalars['Int']['input']; +}; + + +export type MutationUpdateEducationArgs = { + data: UpdateEducationInput; +}; + + +export type MutationUpdateExperienceArgs = { + data: UpdateExperienceInput; +}; + + +export type MutationUpdateProjectArgs = { + data: UpdateProjectInput; +}; + + +export type MutationUpdateSkillArgs = { + data: UpdateSkillInput; + id: Scalars['Int']['input']; +}; + + +export type MutationValidateCaptchaArgs = { + challengeType: Scalars['String']['input']; + idCaptcha: Scalars['String']['input']; + selectedIndices: Array; +}; + +export type Project = { + __typename?: 'Project'; + contentDisplay: Scalars['String']['output']; + descriptionEN: Scalars['String']['output']; + descriptionFR: Scalars['String']['output']; + github?: Maybe; + id: Scalars['ID']['output']; + skills: Array; + title: Scalars['String']['output']; + typeDisplay: Scalars['String']['output']; +}; + +export type ProjectResponse = { + __typename?: 'ProjectResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + project?: Maybe; +}; + +export type ProjectsResponse = { + __typename?: 'ProjectsResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + projects?: Maybe>; +}; + +export type Query = { + __typename?: 'Query'; + educationById: EducationResponse; + educationList: EducationsResponse; + experienceById: ExperienceResponse; + experienceList: ExperiencesResponse; + generateCaptcha: CaptchaResponse; + getGlobalStats: GlobalStatsResponse; + listBackupFiles: BackupFilesResponse; + projectById: ProjectResponse; + projectList: ProjectsResponse; + skillList: CategoryResponse; + userList: UsersResponse; +}; + + +export type QueryEducationByIdArgs = { + id: Scalars['Int']['input']; +}; + + +export type QueryExperienceByIdArgs = { + id: Scalars['Int']['input']; +}; + + +export type QueryProjectByIdArgs = { + id: Scalars['Int']['input']; +}; + +export type Response = { + __typename?: 'Response'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; +}; + +/** User roles */ +export enum Role { + Admin = 'admin', + Editor = 'editor', + View = 'view' +} + +export type Skill = { + __typename?: 'Skill'; + categoryEN: Scalars['String']['output']; + categoryFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + skills: Array; +}; + +export type SkillSubItem = { + __typename?: 'SkillSubItem'; + categoryId: Scalars['Float']['output']; + id: Scalars['ID']['output']; + image: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type SubItemResponse = { + __typename?: 'SubItemResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + subItems?: Maybe>; +}; + +export type UpdateCategoryInput = { + categoryEN?: InputMaybe; + categoryFR?: InputMaybe; +}; + +export type UpdateEducationInput = { + diplomaLevelEN?: InputMaybe; + diplomaLevelFR?: InputMaybe; + endDateEN?: InputMaybe; + endDateFR?: InputMaybe; + id: Scalars['Int']['input']; + location?: InputMaybe; + month?: InputMaybe; + school?: InputMaybe; + startDateEN?: InputMaybe; + startDateFR?: InputMaybe; + titleEN?: InputMaybe; + titleFR?: InputMaybe; + typeEN?: InputMaybe; + typeFR?: InputMaybe; + year?: InputMaybe; +}; + +export type UpdateExperienceInput = { + business?: InputMaybe; + employmentContractEN?: InputMaybe; + employmentContractFR?: InputMaybe; + endDateEN?: InputMaybe; + endDateFR?: InputMaybe; + id: Scalars['Int']['input']; + jobEN?: InputMaybe; + jobFR?: InputMaybe; + month?: InputMaybe; + startDateEN?: InputMaybe; + startDateFR?: InputMaybe; + typeEN?: InputMaybe; + typeFR?: InputMaybe; +}; + +export type UpdateProjectInput = { + contentDisplay?: InputMaybe; + descriptionEN?: InputMaybe; + descriptionFR?: InputMaybe; + github?: InputMaybe; + id: Scalars['Int']['input']; + skillIds?: InputMaybe>; + title?: InputMaybe; + typeDisplay?: InputMaybe; +}; + +export type UpdateSkillInput = { + categoryId: Scalars['Int']['input']; + image?: InputMaybe; + name?: InputMaybe; +}; + +export type User = { + __typename?: 'User'; + email: Scalars['String']['output']; + firstname: Scalars['String']['output']; + id: Scalars['ID']['output']; + isPasswordChange: Scalars['Boolean']['output']; + lastname: Scalars['String']['output']; + role: Role; +}; + +export type UserResponse = { + __typename?: 'UserResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + user?: Maybe; +}; + +export type UsersResponse = { + __typename?: 'UsersResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + users?: Maybe>; +}; + +export type ValidationResponse = { + __typename?: 'ValidationResponse'; + isValid: Scalars['Boolean']['output']; +}; diff --git a/backend/src/types/message.types.ts b/backend/src/types/message.types.ts index 532ec78b..79d13040 100644 --- a/backend/src/types/message.types.ts +++ b/backend/src/types/message.types.ts @@ -1,10 +1,3 @@ -// export type MessageType = { -// label: string; -// message : string | unknown; -// status : boolean; -// } - - import { ObjectType, Field } from "type-graphql"; @ObjectType() diff --git a/backend/src/types/resolvers-types.ts b/backend/src/types/resolvers-types.ts index e6bcecdd..3e5f257f 100644 --- a/backend/src/types/resolvers-types.ts +++ b/backend/src/types/resolvers-types.ts @@ -45,6 +45,23 @@ export type ContactFrom = { object: Scalars['String']['input']; }; +export type Experience = { + __typename?: 'Experience'; + business: Scalars['String']['output']; + employmentContractEN?: Maybe; + employmentContractFR?: Maybe; + endDateEN: Scalars['String']['output']; + endDateFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + jobEN: Scalars['String']['output']; + jobFR: Scalars['String']['output']; + month?: Maybe; + startDateEN: Scalars['String']['output']; + startDateFR: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; +}; + export type MessageType = { __typename?: 'MessageType'; label: Scalars['String']['output']; @@ -76,10 +93,38 @@ export type MutationValidateCaptchaArgs = { selectedIndices: Array; }; +export type Project = { + __typename?: 'Project'; + contentDisplay: Scalars['String']['output']; + descriptionEN: Scalars['String']['output']; + descriptionFR: Scalars['String']['output']; + github?: Maybe; + id: Scalars['ID']['output']; + skills: Array; + title: Scalars['String']['output']; + typeDisplay: Scalars['String']['output']; +}; + export type Query = { __typename?: 'Query'; - contact: Scalars['String']['output']; + experienceList: Array; generateCaptcha: CaptchaResponse; + projectsList: Array; + skillCategoriesList: Array; +}; + +export type Skill = { + __typename?: 'Skill'; + categoryEN: Scalars['String']['output']; + categoryFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + skills: Array; +}; + +export type SkillSubItem = { + __typename?: 'SkillSubItem'; + image: Scalars['String']['output']; + name: Scalars['String']['output']; }; export type ValidationResponse = { @@ -164,10 +209,15 @@ export type ResolversTypes = ResolversObject<{ CaptchaResponse: ResolverTypeWrapper; ChallengeTypeTranslation: ResolverTypeWrapper; ContactFrom: ContactFrom; + Experience: ResolverTypeWrapper; Float: ResolverTypeWrapper; + ID: ResolverTypeWrapper; MessageType: ResolverTypeWrapper; Mutation: ResolverTypeWrapper<{}>; + Project: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; + Skill: ResolverTypeWrapper; + SkillSubItem: ResolverTypeWrapper; String: ResolverTypeWrapper; ValidationResponse: ResolverTypeWrapper; }>; @@ -179,10 +229,15 @@ export type ResolversParentTypes = ResolversObject<{ CaptchaResponse: CaptchaResponse; ChallengeTypeTranslation: ChallengeTypeTranslation; ContactFrom: ContactFrom; + Experience: Experience; Float: Scalars['Float']['output']; + ID: Scalars['ID']['output']; MessageType: MessageType; Mutation: {}; + Project: Project; Query: {}; + Skill: Skill; + SkillSubItem: SkillSubItem; String: Scalars['String']['output']; ValidationResponse: ValidationResponse; }>; @@ -210,6 +265,23 @@ export type ChallengeTypeTranslationResolvers; }>; +export type ExperienceResolvers = ResolversObject<{ + business?: Resolver; + employmentContractEN?: Resolver, ParentType, ContextType>; + employmentContractFR?: Resolver, ParentType, ContextType>; + endDateEN?: Resolver; + endDateFR?: Resolver; + id?: Resolver; + jobEN?: Resolver; + jobFR?: Resolver; + month?: Resolver, ParentType, ContextType>; + startDateEN?: Resolver; + startDateFR?: Resolver; + typeEN?: Resolver; + typeFR?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type MessageTypeResolvers = ResolversObject<{ label?: Resolver; message?: Resolver; @@ -223,9 +295,37 @@ export type MutationResolvers>; }>; +export type ProjectResolvers = ResolversObject<{ + contentDisplay?: Resolver; + descriptionEN?: Resolver; + descriptionFR?: Resolver; + github?: Resolver, ParentType, ContextType>; + id?: Resolver; + skills?: Resolver, ParentType, ContextType>; + title?: Resolver; + typeDisplay?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type QueryResolvers = ResolversObject<{ - contact?: Resolver; + experienceList?: Resolver, ParentType, ContextType>; generateCaptcha?: Resolver; + projectsList?: Resolver, ParentType, ContextType>; + skillCategoriesList?: Resolver, ParentType, ContextType>; +}>; + +export type SkillResolvers = ResolversObject<{ + categoryEN?: Resolver; + categoryFR?: Resolver; + id?: Resolver; + skills?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}>; + +export type SkillSubItemResolvers = ResolversObject<{ + image?: Resolver; + name?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; }>; export type ValidationResponseResolvers = ResolversObject<{ @@ -237,9 +337,13 @@ export type Resolvers = ResolversObject<{ CaptchaImage?: CaptchaImageResolvers; CaptchaResponse?: CaptchaResponseResolvers; ChallengeTypeTranslation?: ChallengeTypeTranslationResolvers; + Experience?: ExperienceResolvers; MessageType?: MessageTypeResolvers; Mutation?: MutationResolvers; + Project?: ProjectResolvers; Query?: QueryResolvers; + Skill?: SkillResolvers; + SkillSubItem?: SkillSubItemResolvers; ValidationResponse?: ValidationResponseResolvers; }>; diff --git a/backend/src/uploads/images/NotesApp.png b/backend/src/uploads/images/NotesApp.png new file mode 100644 index 00000000..4fea5352 Binary files /dev/null and b/backend/src/uploads/images/NotesApp.png differ diff --git a/backend/src/uploads/images/Portfolio.png b/backend/src/uploads/images/Portfolio.png new file mode 100644 index 00000000..24a9c625 Binary files /dev/null and b/backend/src/uploads/images/Portfolio.png differ diff --git a/backend/src/uploads/images/Semantik.png b/backend/src/uploads/images/Semantik.png new file mode 100644 index 00000000..0db07750 Binary files /dev/null and b/backend/src/uploads/images/Semantik.png differ diff --git a/backend/src/uploads/images/Tchat.png b/backend/src/uploads/images/Tchat.png new file mode 100644 index 00000000..8f9382c1 Binary files /dev/null and b/backend/src/uploads/images/Tchat.png differ diff --git a/backend/src/uploads/videos/dailyLog.mp4 b/backend/src/uploads/videos/dailyLog.mp4 new file mode 100644 index 00000000..44f351ab Binary files /dev/null and b/backend/src/uploads/videos/dailyLog.mp4 differ diff --git a/frontend/public/videos/fastNewsApplication.mp4 b/backend/src/uploads/videos/fastNewsApplication.mp4 similarity index 100% rename from frontend/public/videos/fastNewsApplication.mp4 rename to backend/src/uploads/videos/fastNewsApplication.mp4 diff --git a/frontend/public/videos/guessWhatApp.mp4 b/backend/src/uploads/videos/guessWhatApp.mp4 similarity index 100% rename from frontend/public/videos/guessWhatApp.mp4 rename to backend/src/uploads/videos/guessWhatApp.mp4 diff --git a/frontend/public/videos/hermione.mp4 b/backend/src/uploads/videos/hermione.mp4 similarity index 100% rename from frontend/public/videos/hermione.mp4 rename to backend/src/uploads/videos/hermione.mp4 diff --git a/frontend/public/videos/makesense.mp4 b/backend/src/uploads/videos/makesense.mp4 similarity index 100% rename from frontend/public/videos/makesense.mp4 rename to backend/src/uploads/videos/makesense.mp4 diff --git a/frontend/public/videos/wildCodeHub.mp4 b/backend/src/uploads/videos/wildCodeHub.mp4 similarity index 100% rename from frontend/public/videos/wildCodeHub.mp4 rename to backend/src/uploads/videos/wildCodeHub.mp4 diff --git a/frontend/public/videos/wonderMatch.mp4 b/backend/src/uploads/videos/wonderMatch.mp4 similarity index 100% rename from frontend/public/videos/wonderMatch.mp4 rename to backend/src/uploads/videos/wonderMatch.mp4 diff --git a/backend/tests/libs/badgeGenerator.test.ts b/backend/tests/libs/badgeGenerator.test.ts new file mode 100644 index 00000000..d7cb5adb --- /dev/null +++ b/backend/tests/libs/badgeGenerator.test.ts @@ -0,0 +1,69 @@ +import { generateBadgeSvg } from '../../src/lib/badgeGenerator'; + +describe('badgeGenerator', () => { + it('generates a valid SVG string containing the label and message', () => { + const svg = generateBadgeSvg('Langage', 'JavaScript', 'blue'); + expect(svg).toContain(' { + const label = ''; + const message = 'Message"\''; + const svg = generateBadgeSvg(label, message, 'green'); + expect(svg).toContain('<Label&>'); + expect(svg).toContain('Message"''); + }); + + it('uses default labelColor when not provided', () => { + const svg = generateBadgeSvg('Test', 'Value', 'red'); + expect(svg).toContain('fill="555"'); + }); + + it('uses the given labelColor if provided', () => { + const svg = generateBadgeSvg('Test', 'Value', 'red', '#123abc'); + expect(svg).toContain('fill="#123abc"'); + }); + + it('includes the logo image tag with correct href and position left by default', () => { + const logoData = { + base64: 'abc123==', + mimeType: 'image/png', + }; + const svg = generateBadgeSvg('Label', 'Msg', 'orange', undefined, logoData); + expect(svg).toContain(`xlink:href="data:image/png;base64,abc123=="`); + + expect(svg).toMatch(/ { + const logoData = { + base64: 'def456==', + mimeType: 'image/svg+xml', + }; + const svg = generateBadgeSvg('Left', 'Right', 'purple', '444', logoData, 'white', 'right'); + + expect(svg).toContain(`xlink:href="data:image/svg+xml;base64,def456=="`); + + const xMatch = svg.match(/ { + const svg = generateBadgeSvg('', '', 'black'); + expect(svg).toContain(' { + const svg = generateBadgeSvg('A', 'B', '#fff', '#000'); + expect(svg.startsWith('\n { + let context: MyContext; + + beforeEach(() => { + context = { + req: {} as any, + res: {} as any, + cookies: {} as any, + apiKey: 'test-api-key', + user: null, + }; + }); + + it("denies access if no user", () => { + const result = customAuthChecker({ context }, []); + expect(result).toBe(false); + }); + + it("allows access if no roles required and user is present", () => { + context.user = { + id: 1, + email: "test@example.com", + firstname: "Test", + lastname: "User", + role: UserRole.view, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, []); + expect(result).toBe(true); + }); + + it("allows access if user role matches required role", () => { + context.user = { + id: 1, + email: "admin@example.com", + firstname: "Admin", + lastname: "User", + role: UserRole.admin, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin]); + expect(result).toBe(true); + }); + + it("denies access if user role does not match required role", () => { + context.user = { + id: 1, + email: "user@example.com", + firstname: "User", + lastname: "User", + role: UserRole.view, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin]); + expect(result).toBe(false); + }); + + // --- New tests --- + + it("allows access if user role is among multiple required roles", () => { + context.user = { + id: 2, + email: "editor@example.com", + firstname: "Editor", + lastname: "User", + role: UserRole.editor, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin, UserRole.editor]); + expect(result).toBe(true); + }); + + it("denies access if user role is not among multiple required roles", () => { + context.user = { + id: 3, + email: "viewer@example.com", + firstname: "Viewer", + lastname: "User", + role: UserRole.view, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin, UserRole.editor]); + expect(result).toBe(false); + }); + + it("allows access if required roles contain duplicates and user role matches", () => { + context.user = { + id: 4, + email: "admin2@example.com", + firstname: "Admin2", + lastname: "User", + role: UserRole.admin, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin, UserRole.admin]); + expect(result).toBe(true); + }); + + it("denies access if user role is an empty string", () => { + context.user = { + id: 5, + email: "emptyrole@example.com", + firstname: "Empty", + lastname: "Role", + role: "" as any, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, [UserRole.admin]); + expect(result).toBe(false); + }); + + it("allows access if user role is 'view' and no roles are required", () => { + context.user = { + id: 6, + email: "viewuser@example.com", + firstname: "View", + lastname: "User", + role: UserRole.view, + isPasswordChange: false, + }; + const result = customAuthChecker({ context }, []); + expect(result).toBe(true); + }); + +}); \ No newline at end of file diff --git a/backend/tests/libs/generateSecurePassword.test.ts b/backend/tests/libs/generateSecurePassword.test.ts new file mode 100644 index 00000000..925868e4 --- /dev/null +++ b/backend/tests/libs/generateSecurePassword.test.ts @@ -0,0 +1,94 @@ +import { generateSecurePassword } from '../../src/lib/generateSecurePassword'; + +describe('generateSecurePassword', () => { + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const symbols = '!@#$%^&*()_+[]{}|;:,.<>?'; + const all = uppercase + lowercase + numbers + symbols; + + const hasCharFrom = (str: string, charSet: string) => + [...str].some(char => charSet.includes(char)); + + it('should generate a password with minimum length of 9', () => { + const password = generateSecurePassword(); + expect(password.length).toBeGreaterThanOrEqual(9); + }); + + it('should include at least one uppercase letter', () => { + const password = generateSecurePassword(); + expect(hasCharFrom(password, uppercase)).toBe(true); + }); + + it('should include at least one lowercase letter', () => { + const password = generateSecurePassword(); + expect(hasCharFrom(password, lowercase)).toBe(true); + }); + + it('should include at least one number', () => { + const password = generateSecurePassword(); + expect(hasCharFrom(password, numbers)).toBe(true); + }); + + it('should include at least one symbol', () => { + const password = generateSecurePassword(); + expect(hasCharFrom(password, symbols)).toBe(true); + }); + + it('should generate different passwords on multiple calls', () => { + const pwd1 = generateSecurePassword(); + const pwd2 = generateSecurePassword(); + expect(pwd1).not.toBe(pwd2); + }); + + it('should only contain characters from the allowed set', () => { + const password = generateSecurePassword(); + for (const char of password) { + expect(all.includes(char)).toBe(true); + } + }); + it('should always include required character types over multiple generations', () => { + for (let i = 0; i < 100; i++) { + const password = generateSecurePassword(); + expect(password.length).toBeGreaterThanOrEqual(9); + expect(hasCharFrom(password, uppercase)).toBe(true); + expect(hasCharFrom(password, lowercase)).toBe(true); + expect(hasCharFrom(password, numbers)).toBe(true); + expect(hasCharFrom(password, symbols)).toBe(true); + for (const char of password) { + expect(all.includes(char)).toBe(true); + } + } + }); + + it('should generate passwords with some variability in characters used', () => { + const passwords = new Set(); + for (let i = 0; i < 50; i++) { + passwords.add(generateSecurePassword()); + } + expect(passwords.size).toBeGreaterThan(45); + }); + + it('should generate passwords of exactly 9 characters', () => { + const password = generateSecurePassword(); + expect(password.length).toBe(9); + }); + + it('should not contain whitespace or invisible characters', () => { + const password = generateSecurePassword(); + expect(/\s/.test(password)).toBe(false); + }); + + it('should not have a single character repeated excessively', () => { + const password = generateSecurePassword(); + const counts = password.split('').reduce((acc, char) => { + acc[char] = (acc[char] || 0) + 1; + return acc; + }, {} as Record); + + const maxAllowedRepeat = 4; + for (const count of Object.values(counts)) { + expect(count).toBeLessThanOrEqual(maxAllowedRepeat); + } + }); +}); \ No newline at end of file diff --git a/backend/tests/libs/logoLoader.test.ts b/backend/tests/libs/logoLoader.test.ts new file mode 100644 index 00000000..b0b6bc27 --- /dev/null +++ b/backend/tests/libs/logoLoader.test.ts @@ -0,0 +1,131 @@ +import fs from 'fs'; +import path from 'path'; +import { loadLogos, getMimeType, loadedLogos } from '../../src/lib/logoLoader'; + +jest.mock('fs'); + +const LOGOS_DIR = path.join(__dirname, '../../src/images/logos'); + +describe('logoLoader', () => { + beforeEach(() => { + jest.clearAllMocks(); + loadedLogos.clear(); + }); + + describe('getMimeType', () => { + it('returns correct MIME type for supported extensions', () => { + expect(getMimeType('image.png')).toBe('image/png'); + expect(getMimeType('image.svg')).toBe('image/svg+xml'); + expect(getMimeType('image.jpg')).toBe('image/jpeg'); + }); + + it('returns application/octet-stream for unsupported extensions', () => { + expect(getMimeType('file.xyz')).toBe('application/octet-stream'); + }); + + it('is case-insensitive when determining MIME types', () => { + expect(getMimeType('image.PNG')).toBe('image/png'); + expect(getMimeType('image.SvG')).toBe('image/svg+xml'); + expect(getMimeType('image.JpG')).toBe('image/jpeg'); + }); + }); + + describe('loadLogos', () => { + it('loads valid logo files and adds them to the map', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(['logo1.png', 'logo2.svg']); + + // Simulate file contents + (fs.readFileSync as jest.Mock).mockImplementation((filePath) => { + if (filePath.includes('logo1.png')) return Buffer.from('image data 1'); + if (filePath.includes('logo2.svg')) return Buffer.from('image data 2'); + throw new Error('File not found'); + }); + + loadLogos(); + + expect(fs.readdirSync).toHaveBeenCalledWith(LOGOS_DIR); + expect(loadedLogos.size).toBe(2); + expect(loadedLogos.has('logo1')).toBe(true); + expect(loadedLogos.has('logo2')).toBe(true); + }); + + it('ignores files with unsupported extensions', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(['logo1.png', 'ignore.me']); + + (fs.readFileSync as jest.Mock).mockImplementation((filePath) => { + if (filePath.includes('logo1.png')) return Buffer.from('image data 1'); + throw new Error('File not found'); + }); + + loadLogos(); + + expect(loadedLogos.size).toBe(1); + expect(loadedLogos.has('logo1')).toBe(true); + expect(loadedLogos.has('ignore')).toBe(false); + }); + + it('handles errors when reading files without crashing', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(['logo1.png', 'logo2.svg']); + + (fs.readFileSync as jest.Mock).mockImplementation((filePath) => { + if (filePath.includes('logo1.png')) return Buffer.from('image data 1'); + throw new Error('File read error'); + }); + + loadLogos(); + + expect(loadedLogos.size).toBe(1); + expect(loadedLogos.has('logo1')).toBe(true); + expect(loadedLogos.has('logo2')).toBe(false); + }); + + it('handles errors when reading the directory without crashing', () => { + (fs.readdirSync as jest.Mock).mockImplementation(() => { + throw new Error('Directory read error'); + }); + + loadLogos(); + + expect(loadedLogos.size).toBe(0); + }); + + it('handles an empty directory without crashing', () => { + (fs.readdirSync as jest.Mock).mockReturnValue([]); + + loadLogos(); + + expect(loadedLogos.size).toBe(0); + }); + + it('handles filenames with multiple dots correctly', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(['logo.test.png']); + (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from('data')); + + loadLogos(); + + expect(loadedLogos.size).toBe(1); + expect(loadedLogos.has('logo.test')).toBe(true); + }); + + it('stores logos with filename as key without extension', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(['sample-logo.svg']); + (fs.readFileSync as jest.Mock).mockReturnValue(Buffer.from('data')); + + loadLogos(); + + expect(loadedLogos.has('sample-logo')).toBe(true); + }); + + it('does nothing if directory reading returns null or undefined', () => { + (fs.readdirSync as jest.Mock).mockReturnValue(null); + + loadLogos(); + expect(loadedLogos.size).toBe(0); + + (fs.readdirSync as jest.Mock).mockReturnValue(undefined); + + loadLogos(); + expect(loadedLogos.size).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/mail/sendEmail.test.ts b/backend/tests/mail/sendEmail.test.ts new file mode 100644 index 00000000..8e21138a --- /dev/null +++ b/backend/tests/mail/sendEmail.test.ts @@ -0,0 +1,193 @@ +import nodemailer from 'nodemailer'; + +const mockSendMail = jest.fn(); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(), +})); + +describe('sendEmail', () => { + let sendEmail: ( + email: string, + subject: string, + text: string, + html: string, + sendToMe?: boolean + ) => Promise; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should send email to the provided email address when sendToMe is false', async () => { + process.env.AUTH_USER_MAIL = 'user@example.com'; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockResolvedValue(true); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + const result = await sendEmail( + 'test@example.com', + 'Subject', + 'Text content', + '

HTML content

', + false + ); + + expect(nodemailer.createTransport).toHaveBeenCalled(); + expect(mockSendMail).toHaveBeenCalledWith({ + from: 'user@example.com', + to: 'test@example.com', + subject: 'Subject', + text: 'Text content', + html: '

HTML content

', + }); + expect(result).toEqual({ label: 'emailSent', message: 'Email sent', status: true }); + }); + }); + + it('should send email to the user email when sendToMe is true', async () => { + process.env.AUTH_USER_MAIL = 'user@example.com'; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockResolvedValue(true); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + const result = await sendEmail( + 'ignored@example.com', + 'Subject', + 'Text content', + '

HTML content

', + true + ); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'user@example.com' }) + ); + expect(result.status).toBe(true); + }); + }); + + it('should return error message if sendMail throws an error', async () => { + process.env.AUTH_USER_MAIL = 'user@example.com'; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockRejectedValue(new Error('Failed to send email')); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + const result = await sendEmail( + 'test@example.com', + 'Subject', + 'Text content', + '

HTML content

' + ); + + expect(result.status).toBe(false); + expect(result.label).toBe('emailNoSent'); + expect(result.message).toBe('Failed to send email'); + }); + }); + + it('should handle unknown errors gracefully', async () => { + process.env.AUTH_USER_MAIL = 'user@example.com'; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockRejectedValue('Unexpected failure'); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + const result = await sendEmail( + 'test@example.com', + 'Subject', + 'Text content', + '

HTML content

' + ); + + expect(result.status).toBe(false); + expect(result.label).toBe('emailNoSent'); + expect(result.message).toBe('Unknown error'); + }); + }); + + // --- Nouveaux tests --- + + it('should throw error if AUTH_USER_MAIL env is not set', async () => { + delete process.env.AUTH_USER_MAIL; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockResolvedValue(true); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + // Même si user est undefined, la fonction tente d’envoyer un email, + // on vérifie donc que le from est undefined (ou vide) + const result = await sendEmail( + 'someone@example.com', + 'Subject', + 'Text content', + '

HTML content

' + ); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ from: undefined, to: 'someone@example.com' }) + ); + expect(result.status).toBe(true); + }); + }); + + it('should send email with empty html or text without error', async () => { + process.env.AUTH_USER_MAIL = 'user@example.com'; + + (nodemailer.createTransport as jest.Mock).mockImplementation(() => ({ + sendMail: mockSendMail, + })); + + mockSendMail.mockResolvedValue(true); + + await jest.isolateModulesAsync(async () => { + const mailModule = await import('../../src/mail/mail.service'); + sendEmail = mailModule.sendEmail; + + const result = await sendEmail( + 'test@example.com', + 'Subject', + '', + '', + false + ); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ text: '', html: '' }) + ); + expect(result.status).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/mail/structureMail.service.test.ts b/backend/tests/mail/structureMail.service.test.ts new file mode 100644 index 00000000..0b78e6c7 --- /dev/null +++ b/backend/tests/mail/structureMail.service.test.ts @@ -0,0 +1,91 @@ +import { + structureMessageMeTEXT, + structureMessageMeHTML, + structureMessageCreatedAccountTEXT, + structureMessageCreatedAccountHTML, +} from '../../src/mail/structureMail.service'; + +import { ContactFrom } from '../../src/types/contact.types'; + +describe('structureMail.service', () => { + const contactData: ContactFrom = { + email: 'test@example.com', + message: 'Bonjour, ceci est un message test.', + object: 'Coucou', + }; + + describe('structureMessageMeTEXT', () => { + it('should return a plain text message including email and message', () => { + const result = structureMessageMeTEXT(contactData); + expect(result).toContain(contactData.message); + expect(result).toContain(contactData.email); + expect(result).toMatch(/Information sur l'email/); + }); + + it('should handle empty message and email in structureMessageMeTEXT', () => { + const emptyData: ContactFrom = { email: '', message: '', object: '' }; + const result = structureMessageMeTEXT(emptyData); + expect(result).toContain('Email : '); + expect(result).toContain('\n'); + }); + }); + + describe('structureMessageMeHTML', () => { + it('should return an HTML message including email and message', () => { + const result = structureMessageMeHTML(contactData); + expect(result).toContain(`

${contactData.message}

`); + expect(result).toContain(`

Email : ${contactData.email}

`); + expect(result).toContain('
'); + }); + + it('should handle empty message and email in structureMessageMeHTML', () => { + const emptyData: ContactFrom = { email: '', message: '', object: '' }; + const result = structureMessageMeHTML(emptyData); + expect(result).toContain('

'); + expect(result).toContain('

Email :

'); + }); + + it('should render special characters properly in HTML message', () => { + const specialCharData: ContactFrom = { + email: 'test@example.com', + message: 'Bonjour ', + object: 'Test', + }; + const result = structureMessageMeHTML(specialCharData); + expect(result).toContain(`

${specialCharData.message}

`); + }); + }); + + describe('structureMessageCreatedAccountTEXT', () => { + it('should return a plain text account creation message', () => { + const result = structureMessageCreatedAccountTEXT('Alexandre', 'abc123'); + expect(result).toContain('Bonjour Alexandre'); + expect(result).toContain('abc123'); + expect(result).toContain('compte a été créé'); + expect(result).toContain('changer dès votre première connexion'); + }); + + it('should handle empty firstname and password in structureMessageCreatedAccountTEXT', () => { + const result = structureMessageCreatedAccountTEXT('', ''); + expect(result).toContain('Bonjour'); + expect(result).toContain('Votre compte a été créé avec succès'); + expect(result).toContain('Voici votre mot de passe temporaire : '); + }); + }); + + describe('structureMessageCreatedAccountHTML', () => { + it('should return an HTML account creation message', () => { + const result = structureMessageCreatedAccountHTML('Alexandre', 'abc123'); + expect(result).toContain('

Bonjour Alexandre,

'); + expect(result).toContain('Mot de passe temporaire : abc123'); + expect(result).toContain('changer dès votre première connexion'); + }); + + it('should handle empty firstname and password in structureMessageCreatedAccountHTML', () => { + const result = structureMessageCreatedAccountHTML('', ''); + expect(result).toContain('

Bonjour ,

'); + expect(result).toContain('Mot de passe temporaire : '); + expect(result).toContain('Merci de le changer dès votre première connexion'); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/admin/deleteBackupFile.test.ts b/backend/tests/resolvers/admin/deleteBackupFile.test.ts new file mode 100644 index 00000000..04e3ac92 --- /dev/null +++ b/backend/tests/resolvers/admin/deleteBackupFile.test.ts @@ -0,0 +1,102 @@ +import "reflect-metadata"; +import * as path from 'path'; + +// Mock fs dès le début, avant import resolver +jest.mock('fs', () => { + return { + existsSync: jest.fn(), + promises: { + unlink: jest.fn(), + }, + }; +}); + +import * as fs from 'fs'; +import { AdminResolver, dataFolderPath } from '../../../src/resolvers/admin.resolver'; + +describe('AdminResolver - deleteBackupFile', () => { + let resolver: AdminResolver; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + resolver = new AdminResolver(); + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should delete file successfully', async () => { + const fileName = 'backup.sql'; + const filePath = path.join(dataFolderPath, fileName); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.promises.unlink as jest.Mock).mockResolvedValue(undefined); + + const result = await resolver.deleteBackupFile(fileName); + + expect(fs.existsSync).toHaveBeenCalledWith(filePath); + expect(fs.promises.unlink).toHaveBeenCalledWith(filePath); + + expect(result.code).toBe(200); + expect(result.message).toBe(`Backup file '${fileName}' deleted successfully.`); + }); + + it('should reject deletion if path traversal detected', async () => { + const fileName = '../evil.sql'; + + const result = await resolver.deleteBackupFile(fileName); + + expect(result.code).toBe(400); + expect(result.message).toMatch(/Invalid file path/); + expect(fs.existsSync).not.toHaveBeenCalled(); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + + it('should return 404 if file does not exist', async () => { + const fileName = 'missing.sql'; + const filePath = path.join(dataFolderPath, fileName); + + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await resolver.deleteBackupFile(fileName); + + expect(fs.existsSync).toHaveBeenCalledWith(filePath); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + + expect(result.code).toBe(404); + expect(result.message).toBe(`Backup file '${fileName}' not found.`); + }); + + it('should return 500 and log error on unlink failure', async () => { + const fileName = 'fileToDelete.sql'; + const filePath = path.join(dataFolderPath, fileName); + const error = new Error('unlink failed'); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.promises.unlink as jest.Mock).mockRejectedValue(error); + + const result = await resolver.deleteBackupFile(fileName); + + expect(fs.existsSync).toHaveBeenCalledWith(filePath); + expect(fs.promises.unlink).toHaveBeenCalledWith(filePath); + expect(consoleErrorSpy).toHaveBeenCalledWith(`Error deleting backup file '${fileName}':`, error); + + expect(result.code).toBe(500); + expect(result.message).toBe(`Failed to delete backup file '${fileName}': unlink failed`); + }); + + it('should reject deletion if fileName is empty', async () => { + const fileName = ''; + + const result = await resolver.deleteBackupFile(fileName); + + expect(result.code).toBe(400); + expect(result.message).toMatch(/Invalid file path/); + expect(fs.existsSync).not.toHaveBeenCalled(); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + +}); \ No newline at end of file diff --git a/backend/tests/resolvers/admin/generateDatabaseBackup.test.ts b/backend/tests/resolvers/admin/generateDatabaseBackup.test.ts new file mode 100644 index 00000000..41bc8a7c --- /dev/null +++ b/backend/tests/resolvers/admin/generateDatabaseBackup.test.ts @@ -0,0 +1,132 @@ +import "reflect-metadata"; +import * as util from "util"; +import * as child_process from "child_process"; +import * as fs from "fs"; +import { AdminResolver } from "../../../src/resolvers/admin.resolver"; + +jest.mock("fs"); +jest.mock("child_process", () => ({ + exec: jest.fn(), +})); + +describe("AdminResolver - generateDatabaseBackup", () => { + let resolver: AdminResolver; + + const execMock = child_process.exec as jest.MockedFunction; + + const originalEnv = process.env; + + beforeEach(() => { + resolver = new AdminResolver(); + + jest.clearAllMocks(); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.mkdirSync as jest.Mock).mockImplementation(() => {}); + + execMock.mockImplementation((command, optionsOrCallback, maybeCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; + + if (callback) { + callback(null, "stdout fake", ""); + } + + return {} as any; + }); + + process.env = { + ...originalEnv, + DATABASE_URL: "mysql://user:password@localhost:3306/mydatabase", + }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("should create the data folder if it does not exist", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await resolver.generateDatabaseBackup(); + + expect(fs.existsSync).toHaveBeenCalled(); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + expect(execMock).toHaveBeenCalled(); + + expect(result.code).toBe(200); + expect(result.message).toMatch(/Database backup generated successfully/); + // expect(result.path).toMatch(/data\/bdd_\d{8}_\d{6}\.sql/); + expect(result.path).toMatch(/[\\\/]data[\\\/]bdd_\d{8}_\d{6}\.sql/); + }); + + it("should not try to create data folder if it already exists", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + const mkdirSpy = jest.spyOn(fs, "mkdirSync"); + + await resolver.generateDatabaseBackup(); + + expect(mkdirSpy).not.toHaveBeenCalled(); + }); + + it("should run mysqldump command with correct parameters", async () => { + await resolver.generateDatabaseBackup(); + + expect(execMock).toHaveBeenCalled(); + + const callArg = execMock.mock.calls[0][0]; + + expect(callArg).toContain("-h localhost"); + expect(callArg).toContain("-P 3306"); + expect(callArg).toContain("-u user"); + expect(callArg).toContain('-p"password"'); + expect(callArg).toContain("mydatabase"); + expect(callArg).toMatch(/bdd_\d{8}_\d{6}\.sql$/); + }); + + it("should return error response if DATABASE_URL is not set", async () => { + process.env.DATABASE_URL = ""; + + const result = await resolver.generateDatabaseBackup(); + + expect(result.code).toBe(500); + expect(result.message).toMatch(/DATABASE_URL non défini/); + }); + + it("should return error if exec fails", async () => { + execMock.mockImplementation((command, optionsOrCallback, maybeCallback) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; + + if (callback) { + callback(new Error("exec error"), "", ""); + } + + return {} as any; + }); + + const result = await resolver.generateDatabaseBackup(); + + expect(result.code).toBe(500); + expect(result.message).toMatch(/exec error/); + }); + + it("should return error if mkdirSync fails", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.mkdirSync as jest.Mock).mockImplementation(() => { + throw new Error("Permission denied"); + }); + + const result = await resolver.generateDatabaseBackup(); + + expect(result.code).toBe(500); + expect(result.message).toMatch(/Permission denied/); + }); + + it("should return the backup path in response", async () => { + const result = await resolver.generateDatabaseBackup(); + + expect(result.code).toBe(200); + expect(result.path).toBeDefined(); + // expect(result.path).toMatch(/data\/bdd_\d{8}_\d{6}\.sql/); + expect(result.path).toMatch(/[\\\/]data[\\\/]bdd_\d{8}_\d{6}\.sql/); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/admin/getGlobalStats.test.ts b/backend/tests/resolvers/admin/getGlobalStats.test.ts new file mode 100644 index 00000000..e91962ef --- /dev/null +++ b/backend/tests/resolvers/admin/getGlobalStats.test.ts @@ -0,0 +1,154 @@ +import "reflect-metadata"; +import { AdminResolver } from '../../../src/resolvers/admin.resolver'; +import { UserRole } from '../../../src/entities/user.entity'; + +describe("AdminResolver.getGlobalStats", () => { + let mockDb: any; + let resolver: AdminResolver; + + beforeEach(() => { + mockDb = { + user: { + count: jest.fn(), + groupBy: jest.fn(), + }, + project: { count: jest.fn() }, + skill: { count: jest.fn() }, + education: { count: jest.fn() }, + experience: { count: jest.fn() }, + }; + + resolver = new AdminResolver(mockDb); + }); + + it("should return correct global stats", async () => { + mockDb.user.count.mockResolvedValue(10); + mockDb.project.count.mockResolvedValue(5); + mockDb.skill.count.mockResolvedValue(15); + mockDb.education.count.mockResolvedValue(7); + mockDb.experience.count.mockResolvedValue(8); + + mockDb.user.groupBy.mockResolvedValue([ + { role: UserRole.admin, _count: { id: 3 } }, + { role: UserRole.editor, _count: { id: 4 } }, + { role: UserRole.view, _count: { id: 3 } }, + ]); + + const result = await resolver.getGlobalStats(); + + expect(result.code).toBe(200); + expect(result.stats).toBeDefined(); + + if (result.stats) { + expect(result.stats.totalUsers).toBe(10); + expect(result.stats.usersByRoleAdmin).toBe(3); + expect(result.stats.usersByRoleEditor).toBe(4); + expect(result.stats.usersByRoleView).toBe(3); + } + + expect(mockDb.user.count).toHaveBeenCalled(); + expect(mockDb.user.groupBy).toHaveBeenCalled(); + }); + + it("should return error code and message if user.count throws", async () => { + mockDb.user.count.mockRejectedValue(new Error("DB error")); + + const result = await resolver.getGlobalStats(); + + expect(result.code).not.toBe(200); + expect(result.stats).toBeUndefined(); + expect(result.message).toBeDefined(); + }); + + it("should handle empty users groupBy gracefully", async () => { + mockDb.user.count.mockResolvedValue(0); + mockDb.user.groupBy.mockResolvedValue([]); + + const result = await resolver.getGlobalStats(); + + expect(result.code).toBe(200); + expect(result.stats).toBeDefined(); + + if (result.stats) { + expect(result.stats.totalUsers).toBe(0); + expect(result.stats.usersByRoleAdmin).toBe(0); + expect(result.stats.usersByRoleEditor).toBe(0); + expect(result.stats.usersByRoleView).toBe(0); + } + }); + + it("should call count for all entities", async () => { + mockDb.user.count.mockResolvedValue(10); + mockDb.project.count.mockResolvedValue(5); + mockDb.skill.count.mockResolvedValue(15); + mockDb.education.count.mockResolvedValue(7); + mockDb.experience.count.mockResolvedValue(8); + mockDb.user.groupBy.mockResolvedValue([]); + + await resolver.getGlobalStats(); + + expect(mockDb.user.count).toHaveBeenCalled(); + expect(mockDb.project.count).toHaveBeenCalled(); + expect(mockDb.skill.count).toHaveBeenCalled(); + expect(mockDb.education.count).toHaveBeenCalled(); + expect(mockDb.experience.count).toHaveBeenCalled(); + }); + + it("should correctly count users by each role", async () => { + mockDb.user.count.mockResolvedValue(10); + mockDb.user.groupBy.mockResolvedValue([ + { role: UserRole.admin, _count: { id: 2 } }, + { role: UserRole.editor, _count: { id: 5 } }, + { role: UserRole.view, _count: { id: 3 } }, + ]); + + const result = await resolver.getGlobalStats(); + + expect(result.stats).toBeDefined(); + + if (result.stats) { + expect(result.stats.usersByRoleAdmin).toBe(2); + expect(result.stats.usersByRoleEditor).toBe(5); + expect(result.stats.usersByRoleView).toBe(3); + } + }); + + it("should ignore unknown user roles in groupBy", async () => { + mockDb.user.count.mockResolvedValue(5); + mockDb.user.groupBy.mockResolvedValue([ + { role: "superadmin" as any, _count: { id: 5 } }, // rôle inconnu + ]); + + const result = await resolver.getGlobalStats(); + + expect(result.stats).toBeDefined(); + + if (result.stats) { + expect(result.stats.usersByRoleAdmin).toBe(0); + expect(result.stats.usersByRoleEditor).toBe(0); + expect(result.stats.usersByRoleView).toBe(0); + expect(result.stats.totalUsers).toBe(5); + } + }); + + it("should have totalUsers greater or equal to sum of usersByRole", async () => { + mockDb.user.count.mockResolvedValue(10); + mockDb.user.groupBy.mockResolvedValue([ + { role: UserRole.admin, _count: { id: 3 } }, + { role: UserRole.editor, _count: { id: 4 } }, + { role: UserRole.view, _count: { id: 2 } }, + ]); + + const result = await resolver.getGlobalStats(); + + expect(result.stats).toBeDefined(); + + if (result.stats) { + const sumRoles = + (result.stats.usersByRoleAdmin || 0) + + (result.stats.usersByRoleEditor || 0) + + (result.stats.usersByRoleView || 0); + expect(result.stats.totalUsers).toBeGreaterThanOrEqual(sumRoles); + } + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/admin/listBackupFiles.test.ts b/backend/tests/resolvers/admin/listBackupFiles.test.ts new file mode 100644 index 00000000..67dd5eee --- /dev/null +++ b/backend/tests/resolvers/admin/listBackupFiles.test.ts @@ -0,0 +1,91 @@ +import "reflect-metadata"; +import * as fs from 'fs'; +import { AdminResolver } from '../../../src/resolvers/admin.resolver'; + +// Spy uniquement sur fs.promises +jest.spyOn(fs.promises, 'readdir').mockImplementation(jest.fn()); +jest.spyOn(fs.promises, 'stat').mockImplementation(jest.fn()); + +describe('AdminResolver - listBackupFiles', () => { + let resolver: AdminResolver; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + resolver = new AdminResolver(); + + (fs.promises.readdir as jest.Mock).mockReset(); + (fs.promises.stat as jest.Mock).mockReset(); + + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + test('should return empty files if data folder does not exist', async () => { + const enoentError = new Error('not found') as any; + enoentError.code = 'ENOENT'; + + (fs.promises.readdir as jest.Mock).mockRejectedValue(enoentError); + + const result = await resolver.listBackupFiles(); + + expect(result.code).toBe(200); + expect(result.files).toEqual([]); + }); + + test('should list backup files with their stats', async () => { + (fs.promises.readdir as jest.Mock).mockResolvedValue(['file1.sql', 'file2.sql']); + (fs.promises.stat as jest.Mock).mockResolvedValue({ + size: 1234, + mtime: new Date('2025-06-10T10:00:00Z'), + isFile: () => true, + }); + + const result = await resolver.listBackupFiles(); + + expect(result.code).toBe(200); + expect(result.message).toMatch(/Backup files listed successfully/); + expect(result.files).toHaveLength(2); + expect(result.files![0].fileName).toBe('file1.sql'); + }); + + test('should continue if stat fails for a file and log warning', async () => { + (fs.promises.readdir as jest.Mock).mockResolvedValue(['goodfile.sql', 'badfile.sql']); + (fs.promises.stat as jest.Mock) + .mockImplementationOnce(() => + Promise.resolve({ + size: 5678, + mtime: new Date('2025-06-11T12:00:00Z'), + isFile: () => true, + }) + ) + .mockImplementationOnce(() => Promise.reject(new Error('stat error'))); + + const result = await resolver.listBackupFiles(); + + expect(result.code).toBe(200); + expect(result.files).toHaveLength(1); + expect(result.files![0].fileName).toBe('goodfile.sql'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not get stats for file badfile.sql:'), + expect.any(Error) + ); + }); + + test('should return 500 error if readdir throws unexpected error', async () => { + (fs.promises.readdir as jest.Mock).mockRejectedValue(new Error('readdir error')); + + const result = await resolver.listBackupFiles(); + + expect(result.code).toBe(500); + expect(result.message).toMatch(/readdir error/); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error listing backup files:', expect.any(Error)); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/captcha/clearCaptcha.test.ts b/backend/tests/resolvers/captcha/clearCaptcha.test.ts new file mode 100644 index 00000000..57d51e5a --- /dev/null +++ b/backend/tests/resolvers/captcha/clearCaptcha.test.ts @@ -0,0 +1,46 @@ +import "reflect-metadata"; +import { CaptchaResolver } from "../../../src/resolvers/captcha.resolver"; +import { MyContext } from "../../../src"; +import { captchaMap } from '../../../src/CaptchaMap'; + +describe("CaptchaResolver - clearCaptcha", () => { + let resolver: CaptchaResolver; + + const context: MyContext = {} as MyContext; + + const MOCK_CAPTCHA_ID_EXISTS = "test-captcha-to-clear-123"; + const MOCK_CAPTCHA_ID_NON_EXISTENT = "non-existent-captcha-456"; + + const MOCK_CAPTCHA_DATA = { + id: MOCK_CAPTCHA_ID_EXISTS, + images: [], + challengeType: "test", + challengeTypeTranslation: { typeEN: "test", typeFR: "test" }, + expirationTime: Date.now() + 60000, + }; + + beforeEach(() => { + for (const key in captchaMap) { + delete captchaMap[key]; + } + resolver = new CaptchaResolver(); + }); + + it("should return true and delete the captcha if it exists in the map", async () => { + captchaMap[MOCK_CAPTCHA_ID_EXISTS] = MOCK_CAPTCHA_DATA; + + const result = await resolver.clearCaptcha(MOCK_CAPTCHA_ID_EXISTS, context); + + expect(result).toBe(true); + expect(captchaMap[MOCK_CAPTCHA_ID_EXISTS]).toBeUndefined(); + }); + + it("should return true if the captcha does not exist in the map", async () => { + expect(captchaMap[MOCK_CAPTCHA_ID_NON_EXISTENT]).toBeUndefined(); + + const result = await resolver.clearCaptcha(MOCK_CAPTCHA_ID_NON_EXISTENT, context); + + expect(result).toBe(true); + expect(captchaMap[MOCK_CAPTCHA_ID_NON_EXISTENT]).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/captcha/generateCaptcha.test.ts b/backend/tests/resolvers/captcha/generateCaptcha.test.ts new file mode 100644 index 00000000..82757ec6 --- /dev/null +++ b/backend/tests/resolvers/captcha/generateCaptcha.test.ts @@ -0,0 +1,172 @@ +import "reflect-metadata"; +import { CaptchaResolver } from "../../../src/resolvers/captcha.resolver"; +import { MyContext } from "../../../src"; +import { CaptchaResponse } from "../../../src/types/captcha.types"; +import * as uuid from 'uuid'; +import * as fs from 'fs'; +import * as path from 'path'; +import { captchaImageMap, captchaMap } from '../../../src/CaptchaMap'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +jest.mock('fs', () => ({ + readdirSync: jest.fn(), +})); +jest.mock('path', () => ({ + join: jest.fn(), + basename: jest.fn(), + extname: jest.fn(), +})); + +describe("CaptchaResolver - generateCaptcha", () => { + let resolver: CaptchaResolver; + let mockUuidV4: jest.Mock; + let mockReaddirSync: jest.Mock; + let mockPathJoin: jest.Mock; + let mockPathBasename: jest.Mock; + let mockPathExtname: jest.Mock; + + const originalProcessEnv = process.env; + + beforeAll(() => { + // Store original process.env and set mock BASE_URL + process.env = { ...originalProcessEnv, BASE_URL: 'http://test-server:4000' }; + }); + + afterAll(() => { + process.env = originalProcessEnv; + }); + + beforeEach(() => { + mockUuidV4 = uuid.v4 as jest.Mock; + mockReaddirSync = fs.readdirSync as jest.Mock; + mockPathJoin = path.join as jest.Mock; + mockPathBasename = path.basename as jest.Mock; + mockPathExtname = path.extname as jest.Mock; + + for (const key in captchaImageMap) { + delete captchaImageMap[key]; + } + for (const key in captchaMap) { + delete captchaMap[key]; + } + + resolver = new CaptchaResolver(); + + mockPathJoin.mockImplementation((...args: string[]) => args.join('/')); + mockPathBasename.mockImplementation((filePath: string) => { + const parts = filePath.split('/'); + return parts[parts.length - 1].split('.')[0]; + }); + mockPathExtname.mockImplementation((filePath: string) => `.${filePath.split('.').pop()}`); + }); + + it("should generate a captcha with correct structure and images", async () => { + const mockCaptchaId = "captcha-id-123"; + const mockImageIds = ["img-id-1", "img-id-2", "img-id-3", "img-id-4", "img-id-5", "img-id-6"]; + + // Mock uuidv4 calls + mockUuidV4 + .mockReturnValueOnce(mockCaptchaId) + .mockReturnValueOnce(mockImageIds[0]) + .mockReturnValueOnce(mockImageIds[1]) + .mockReturnValueOnce(mockImageIds[2]) + .mockReturnValueOnce(mockImageIds[3]) + .mockReturnValueOnce(mockImageIds[4]) + .mockReturnValueOnce(mockImageIds[5]); + + // Mock fs.readdirSync to return a set of image files + mockReaddirSync.mockReturnValueOnce([ + "car-voiture-1.png", "car-voiture-2.jpeg", "car-voiture-3.jpg", + "tree-arbre-1.png", "tree-arbre-2.jpeg", "tree-arbre-3.jpg", + "boat-bateau-1.png", "boat-bateau-2.jpeg", "boat-bateau-3.jpg", + ]); + + const context: MyContext = {} as MyContext; + + const result: CaptchaResponse = await resolver.generateCaptcha(context); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockCaptchaId); + expect(result.images).toHaveLength(6); // 3 categories * 2 images each + + result.images.forEach((img, index) => { + expect(img.id).toBe(mockImageIds[index]); + expect(img.url).toMatch(`http://test-server:4000/dynamic-images/${mockImageIds[index]}`); + expect(img.typeEN).toBeDefined(); + expect(img.typeFR).toBeDefined(); + expect(captchaImageMap[img.id]).toBeDefined(); + }); + + expect(result.challengeType).toBeDefined(); + expect(result.challengeTypeTranslation.typeEN).toBe(result.challengeType); + expect(result.challengeTypeTranslation.typeFR).toBeDefined(); + expect(result.expirationTime).toBeGreaterThan(Date.now()); + + expect(captchaMap[mockCaptchaId]).toEqual( + expect.objectContaining({ + id: mockCaptchaId, + images: expect.arrayContaining(result.images), + challengeType: result.challengeType, + challengeTypeTranslation: result.challengeTypeTranslation, + expirationTime: expect.any(Number), + }) + ); + + expect(mockPathJoin).toHaveBeenCalledWith(expect.any(String), '..', 'images'); + expect(mockReaddirSync).toHaveBeenCalledWith(expect.any(String)); + }); + + it("should handle no images found in the directory gracefully", async () => { + const mockCaptchaId = "captcha-id-empty"; + mockUuidV4.mockReturnValueOnce(mockCaptchaId); + mockReaddirSync.mockReturnValueOnce([]); + + const context: MyContext = {} as MyContext; + const result: CaptchaResponse = await resolver.generateCaptcha(context); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockCaptchaId); + expect(result.images).toHaveLength(0); + expect(result.challengeType).toBeUndefined(); + expect(result.challengeTypeTranslation).toEqual({ typeEN: undefined, typeFR: '' }); + expect(result.expirationTime).toBeGreaterThan(Date.now()); + + expect(captchaMap[mockCaptchaId]).toEqual( + expect.objectContaining({ + id: mockCaptchaId, + images: [], + challengeType: undefined, + challengeTypeTranslation: { typeEN: undefined, typeFR: '' }, + expirationTime: expect.any(Number), + }) + ); + }); + + it("should generate a captcha with a single category correctly", async () => { + const mockCaptchaId = "captcha-id-single"; + const mockImageIds = ["img-s1", "img-s2"]; + mockUuidV4 + .mockReturnValueOnce(mockCaptchaId) + .mockReturnValueOnce(mockImageIds[0]) + .mockReturnValueOnce(mockImageIds[1]); + + mockReaddirSync.mockReturnValueOnce([ + "car-voiture-1.png", "car-voiture-2.jpeg", "car-voiture-3.jpg", + ]); + + const context: MyContext = {} as MyContext; + const result: CaptchaResponse = await resolver.generateCaptcha(context); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockCaptchaId); + expect(result.images).toHaveLength(2); + expect(result.images[0].typeEN).toBe("car"); + expect(result.images[1].typeEN).toBe("car"); + expect(result.challengeType).toBe("car"); + expect(result.challengeTypeTranslation.typeEN).toBe("car"); + expect(result.challengeTypeTranslation.typeFR).toBe("voiture"); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/captcha/validateCaptcha.test.ts b/backend/tests/resolvers/captcha/validateCaptcha.test.ts new file mode 100644 index 00000000..a9df2f7a --- /dev/null +++ b/backend/tests/resolvers/captcha/validateCaptcha.test.ts @@ -0,0 +1,149 @@ +import "reflect-metadata"; +import { CaptchaResolver } from "../../../src/resolvers/captcha.resolver"; +import { MyContext } from "../../../src"; +import { ValidationResponse, CaptchaResponse } from "../../../src/types/captcha.types"; + +import * as CaptchaMapModule from '../../../src/CaptchaMap'; +import { captchaImageMap, captchaMap } from '../../../src/CaptchaMap'; + +// Mock checkExpiredCaptcha +jest.mock('../../../src/CaptchaMap', () => ({ + ...jest.requireActual('../../../src/CaptchaMap'), + checkExpiredCaptcha: jest.fn(), +})); + +describe("CaptchaResolver - validateCaptcha", () => { + let resolver: CaptchaResolver; + let mockCheckExpiredCaptcha: jest.Mock; + + const context: MyContext = {} as MyContext; + + const MOCK_CAPTCHA_ID = "test-captcha-123"; + const MOCK_CHALLENGE_TYPE = "car"; + const MOCK_CAPTCHA_DATA: CaptchaResponse = { + id: MOCK_CAPTCHA_ID, + images: [ + { id: "img1-id", url: "http://test-server:4000/dynamic-images/img1-id", typeEN: "car", typeFR: "voiture" }, + { id: "img2-id", url: "http://test-server:4000/dynamic-images/img2-id", typeEN: "tree", typeFR: "arbre" }, + { id: "img3-id", url: "http://test-server:4000/dynamic-images/img3-id", typeEN: "car", typeFR: "voiture" }, + { id: "img4-id", url: "http://test-server:4000/dynamic-images/img4-id", typeEN: "boat", typeFR: "bateau" }, + { id: "img5-id", url: "http://test-server:4000/dynamic-images/img5-id", typeEN: "car", typeFR: "voiture" }, + { id: "img6-id", url: "http://test-server:4000/dynamic-images/img6-id", typeEN: "tree", typeFR: "arbre" }, + ], + challengeType: MOCK_CHALLENGE_TYPE, + challengeTypeTranslation: { typeEN: MOCK_CHALLENGE_TYPE, typeFR: "voiture" }, + expirationTime: Date.now() + 15 * 60 * 1000, + }; + + beforeEach(() => { + + mockCheckExpiredCaptcha = CaptchaMapModule.checkExpiredCaptcha as jest.Mock; + mockCheckExpiredCaptcha.mockClear(); + + for (const key in captchaImageMap) { + delete captchaImageMap[key]; + } + for (const key in captchaMap) { + delete captchaMap[key]; + } + + captchaMap[MOCK_CAPTCHA_ID] = { ...MOCK_CAPTCHA_DATA }; + MOCK_CAPTCHA_DATA.images.forEach(img => { + captchaImageMap[img.id] = `dummy-src-for-${img.id}.png`; + }); + + resolver = new CaptchaResolver(); + }); + + it("should return isValid: true and clear maps for a correct captcha validation", async () => { + const correctIndices = [0, 2, 4]; // Indices of 'car' images + + const result: ValidationResponse = await resolver.validateCaptcha( + correctIndices, + MOCK_CHALLENGE_TYPE, + MOCK_CAPTCHA_ID, + context + ); + + expect(result.isValid).toBe(true); + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + expect(mockCheckExpiredCaptcha).toHaveBeenCalledWith(MOCK_CAPTCHA_ID); + + expect(captchaMap[MOCK_CAPTCHA_ID]).toBeUndefined(); + expect(captchaImageMap["img1-id"]).toBeUndefined(); + expect(captchaImageMap["img2-id"]).toBeUndefined(); + expect(captchaImageMap["img3-id"]).toBeUndefined(); + expect(captchaImageMap["img4-id"]).toBeUndefined(); + expect(captchaImageMap["img5-id"]).toBeUndefined(); + expect(captchaImageMap["img6-id"]).toBeUndefined(); + }); + + it("should return isValid: false for incorrect selected indices", async () => { + const incorrectIndices = [0, 1]; + + const result: ValidationResponse = await resolver.validateCaptcha( + incorrectIndices, + MOCK_CHALLENGE_TYPE, + MOCK_CAPTCHA_ID, + context + ); + + expect(result.isValid).toBe(false); + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + + expect(captchaMap[MOCK_CAPTCHA_ID]).toBeDefined(); + expect(captchaImageMap["img1-id"]).toBeDefined(); + expect(captchaImageMap["img2-id"]).toBeDefined(); + }); + + it("should return isValid: false if selected indices count does not match correct count", async () => { + const partialIndices = [0, 2]; // Only 2 selected, but there are 3 correct 'car' images + + const result: ValidationResponse = await resolver.validateCaptcha( + partialIndices, + MOCK_CHALLENGE_TYPE, + MOCK_CAPTCHA_ID, + context + ); + + expect(result.isValid).toBe(false); + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + expect(captchaMap[MOCK_CAPTCHA_ID]).toBeDefined(); + }); + + it("should throw an error if captchaId is not found in captchaMap after checkExpiredCaptcha", async () => { + delete captchaMap[MOCK_CAPTCHA_ID]; + + await expect( + resolver.validateCaptcha([], MOCK_CHALLENGE_TYPE, MOCK_CAPTCHA_ID, context) + ).rejects.toThrow("Expired captcha!"); + + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + }); + + it("should throw an error if images array is null/undefined for the captcha", async () => { + captchaMap[MOCK_CAPTCHA_ID].images = undefined as any; + + await expect( + resolver.validateCaptcha([], MOCK_CHALLENGE_TYPE, MOCK_CAPTCHA_ID, context) + ).rejects.toThrow("Expired captcha!"); + + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + }); + + it("should return isValid: false if challenge type is incorrect", async () => { + const correctIndicesForCar = [0, 2, 4]; + const wrongChallengeType = "boat"; + + const result: ValidationResponse = await resolver.validateCaptcha( + correctIndicesForCar, + wrongChallengeType, + MOCK_CAPTCHA_ID, + context + ); + + expect(result.isValid).toBe(false); + expect(mockCheckExpiredCaptcha).toHaveBeenCalledTimes(1); + expect(captchaMap[MOCK_CAPTCHA_ID]).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/contact/sendContact.test.ts b/backend/tests/resolvers/contact/sendContact.test.ts new file mode 100644 index 00000000..12416e3a --- /dev/null +++ b/backend/tests/resolvers/contact/sendContact.test.ts @@ -0,0 +1,145 @@ +import "reflect-metadata"; +import { ContactResolver } from "../../../src/resolvers/contact.resolver"; +import { MyContext } from "../../../src"; +import { ContactFrom } from "../../../src/types/contact.types"; +import { MessageType } from "../../../src/types/message.types"; + +// Import modules to mock +import * as MailService from "../../../src/mail/mail.service"; +import * as StructureMailService from "../../../src/mail/structureMail.service"; +import * as RegexModule from "../../../src/regex"; + + +jest.mock("../../../src/mail/mail.service"); +jest.mock("../../../src/mail/structureMail.service"); +jest.mock("../../../src/regex"); + +describe("ContactResolver - sendContact", () => { + let resolver: ContactResolver; + let mockSendEmail: jest.Mock; + let mockStructureMessageMeTEXT: jest.Mock; + let mockStructureMessageMeHTML: jest.Mock; + let mockCheckRegex: jest.Mock; + + const context: MyContext = {} as MyContext; + + const mockContactData: ContactFrom = { + email: "john.doe@example.com", + object: "Inquiry about services", + message: "I would like to know more about your offerings.", + }; + + const mockTextEmailBody = "Structured text message"; + const mockHtmlEmailBody = "Structured HTML message"; + + beforeEach(() => { + mockSendEmail = MailService.sendEmail as jest.Mock; + mockStructureMessageMeTEXT = StructureMailService.structureMessageMeTEXT as jest.Mock; + mockStructureMessageMeHTML = StructureMailService.structureMessageMeHTML as jest.Mock; + mockCheckRegex = RegexModule.checkRegex as jest.Mock; + + mockSendEmail.mockClear(); + mockStructureMessageMeTEXT.mockClear(); + mockStructureMessageMeHTML.mockClear(); + mockCheckRegex.mockClear(); + + resolver = new ContactResolver(); + + mockCheckRegex.mockReturnValue(true); + mockStructureMessageMeTEXT.mockResolvedValue(mockTextEmailBody); + mockStructureMessageMeHTML.mockResolvedValue(mockHtmlEmailBody); + }); + + it("should successfully send an email when data is valid", async () => { + const mockSendEmailSuccess: MessageType = { + message: "Email sent successfully", + label: "Success", + status: true, + }; + mockSendEmail.mockResolvedValue(mockSendEmailSuccess); + + const result: MessageType = await resolver.sendContact(mockContactData, context); + + expect(result.message).toBe("Email sent successfully"); + expect(result.label).toBe("Success"); + expect(result.status).toBe(true); + + expect(mockCheckRegex).toHaveBeenCalledTimes(1); + expect(mockCheckRegex).toHaveBeenCalledWith(RegexModule.emailRegex, mockContactData.email); + + expect(mockStructureMessageMeTEXT).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeTEXT).toHaveBeenCalledWith(mockContactData); + expect(mockStructureMessageMeHTML).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeHTML).toHaveBeenCalledWith(mockContactData); + + expect(mockSendEmail).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledWith( + mockContactData.email, + mockContactData.object, + mockTextEmailBody, + mockHtmlEmailBody, + true + ); + }); + + it("should throw an error for an invalid email format", async () => { + mockCheckRegex.mockReturnValue(false); // Simulate invalid email + + await expect(resolver.sendContact(mockContactData, context)).rejects.toThrow( + "Invaid format email." + ); + + expect(mockCheckRegex).toHaveBeenCalledTimes(1); + expect(mockSendEmail).not.toHaveBeenCalled(); + expect(mockStructureMessageMeTEXT).not.toHaveBeenCalled(); + expect(mockStructureMessageMeHTML).not.toHaveBeenCalled(); + }); + + it("should return failure message if sendEmail service fails", async () => { + const mockSendEmailFailure: MessageType = { + message: "Failed to send email due to service error", + label: "Error", + status: false, + }; + mockSendEmail.mockResolvedValue(mockSendEmailFailure); + + const result: MessageType = await resolver.sendContact(mockContactData, context); + + expect(result.message).toBe("Failed to send email due to service error"); + expect(result.label).toBe("Error"); + expect(result.status).toBe(false); + + expect(mockCheckRegex).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeTEXT).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeHTML).toHaveBeenCalledTimes(1); + expect(mockSendEmail).toHaveBeenCalledTimes(1); + }); + + it("should handle error during message structuring (TEXT)", async () => { + const errorMessage = "Error structuring text message"; + mockStructureMessageMeTEXT.mockRejectedValue(new Error(errorMessage)); + + await expect(resolver.sendContact(mockContactData, context)).rejects.toThrow( + errorMessage + ); + + expect(mockCheckRegex).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeTEXT).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeHTML).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + + it("should handle error during message structuring (HTML)", async () => { + const errorMessage = "Error structuring HTML message"; + mockStructureMessageMeHTML.mockRejectedValue(new Error(errorMessage)); + + await expect(resolver.sendContact(mockContactData, context)).rejects.toThrow( + errorMessage + ); + + expect(mockCheckRegex).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeTEXT).toHaveBeenCalledTimes(1); + expect(mockStructureMessageMeHTML).toHaveBeenCalledTimes(1); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/education/createEducation.test.ts b/backend/tests/resolvers/education/createEducation.test.ts new file mode 100644 index 00000000..6d0dd996 --- /dev/null +++ b/backend/tests/resolvers/education/createEducation.test.ts @@ -0,0 +1,126 @@ +import "reflect-metadata"; +import { EducationResolver } from "../../../src/resolvers/education.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { CreateEducationInput } from "../../../src/entities/inputs/education.input"; +import { EducationResponse } from "../../../src/entities/response.types"; +import { Education as PrismaEducation } from "@prisma/client"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("EducationResolver - createEducation", () => { + let resolver: EducationResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockCreateEducationInput: CreateEducationInput = { + titleFR: "Diplôme de Test FR", + titleEN: "Test Degree EN", + diplomaLevelFR: "Master FR", + diplomaLevelEN: "Master EN", + school: "Test University", + location: "Test City", + year: 2023, + startDateFR: "Janvier 2020", + startDateEN: "January 2020", + endDateFR: "Juin 2023", + endDateEN: "June 2023", + month: 2, + typeFR: "Université", + typeEN: "University", + }; + + const mockCreatedEducation: PrismaEducation = { + id: 1, + ...mockCreateEducationInput, + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.education.create.mockReset(); + resolver = new EducationResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully create a new education record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.education.create.mockResolvedValueOnce(mockCreatedEducation); + + const result: EducationResponse = await resolver.createEducation(mockCreateEducationInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Education created"); + expect(result.education).toEqual(mockCreatedEducation); + + expect(prismaMock.education.create).toHaveBeenCalledTimes(1); + expect(prismaMock.education.create).toHaveBeenCalledWith({ data: mockCreateEducationInput }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: EducationResponse = await resolver.createEducation(mockCreateEducationInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.create).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: EducationResponse = await resolver.createEducation(mockCreateEducationInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during education creation", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during education creation"; + prismaMock.education.create.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.createEducation(mockCreateEducationInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error creating education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.create).toHaveBeenCalledTimes(1); + expect(prismaMock.education.create).toHaveBeenCalledWith({ data: mockCreateEducationInput }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/education/deleteEducation.test.ts b/backend/tests/resolvers/education/deleteEducation.test.ts new file mode 100644 index 00000000..a4fde497 --- /dev/null +++ b/backend/tests/resolvers/education/deleteEducation.test.ts @@ -0,0 +1,158 @@ +import "reflect-metadata"; +import { EducationResolver } from "../../../src/resolvers/education.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { EducationResponse } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("EducationResolver - deleteEducation", () => { + let resolver: EducationResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingEducation = { + id: 1, + titleFR: "Diplôme à Supprimer", + titleEN: "Degree to Delete", + diplomaLevelFR: "Master", + diplomaLevelEN: "Master", + school: "University", + location: "City", + year: 2020, + startDateFR: "Jan 2018", + startDateEN: "Jan 2018", + endDateFR: "Juin 2020", + endDateEN: "June 2020", + month: 12, + typeEN: "University", + typeFR: "Université", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.education.findUnique.mockReset(); + prismaMock.education.delete.mockReset(); + resolver = new EducationResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully delete an education record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.education.findUnique.mockResolvedValueOnce(mockExistingEducation); + prismaMock.education.delete.mockResolvedValueOnce(mockExistingEducation); + + const result: EducationResponse = await resolver.deleteEducation(mockExistingEducation.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Education deleted"); + expect(result.education).toBeUndefined(); // Assuming delete operation doesn't return the deleted entity in the response + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingEducation.id } }); + expect(prismaMock.education.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.education.delete).toHaveBeenCalledWith({ where: { id: mockExistingEducation.id } }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: EducationResponse = await resolver.deleteEducation(mockExistingEducation.id, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.education.delete).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: EducationResponse = await resolver.deleteEducation(mockExistingEducation.id, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.education.delete).not.toHaveBeenCalled(); + }); + + it("should return 404 if the education record to delete is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.education.findUnique.mockResolvedValueOnce(null); + + const result: EducationResponse = await resolver.deleteEducation(999, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Education not found"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + expect(prismaMock.education.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during finding the education record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during findUnique"; + prismaMock.education.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.deleteEducation(mockExistingEducation.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during deleting the education record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during delete"; + + prismaMock.education.findUnique.mockResolvedValueOnce(mockExistingEducation); + prismaMock.education.delete.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.deleteEducation(mockExistingEducation.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.delete).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/education/educationById.test.ts b/backend/tests/resolvers/education/educationById.test.ts new file mode 100644 index 00000000..88d848cc --- /dev/null +++ b/backend/tests/resolvers/education/educationById.test.ts @@ -0,0 +1,73 @@ +import "reflect-metadata"; +import { EducationResolver } from "../../../src/resolvers/education.resolver"; +import { prismaMock } from "../../singleton"; +import { EducationResponse } from "../../../src/entities/response.types"; +import { Education as PrismaEducation } from "@prisma/client"; + +describe("EducationResolver - educationById", () => { + let resolver: EducationResolver; + + const mockEducation: PrismaEducation = { + id: 1, + titleFR: "Diplôme de l'École Supérieure", + titleEN: "Higher School Degree", + diplomaLevelFR: "Master 2", + diplomaLevelEN: "Master's Degree", + school: "SupInfo", + location: "Paris, France", + year: 2024, + startDateFR: "Septembre 2019", + startDateEN: "September 2019", + endDateFR: "Juin 2024", + endDateEN: "June 2024", + month: 60, + typeEN: "Engineering School", + typeFR: "École d'Ingénieurs", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.education.findUnique.mockReset(); + resolver = new EducationResolver(prismaMock); + }); + + it("should return an education record by ID successfully", async () => { + prismaMock.education.findUnique.mockResolvedValueOnce(mockEducation); + + const result: EducationResponse = await resolver.educationById(mockEducation.id); + + expect(result.code).toBe(200); + expect(result.message).toBe("Education fetched"); + expect(result.education).toEqual(mockEducation); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: mockEducation.id } }); + }); + + it("should return 404 if the education record is not found", async () => { + prismaMock.education.findUnique.mockResolvedValueOnce(null); + + const result: EducationResponse = await resolver.educationById(999); + + expect(result.code).toBe(404); + expect(result.message).toBe("Education not found"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + }); + + it("should return 500 for an internal server error", async () => { + const errorMessage = "Database query failed"; + prismaMock.education.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.educationById(mockEducation.id); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error fetching education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: mockEducation.id } }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/education/educationList.test.ts b/backend/tests/resolvers/education/educationList.test.ts new file mode 100644 index 00000000..96f4f00d --- /dev/null +++ b/backend/tests/resolvers/education/educationList.test.ts @@ -0,0 +1,92 @@ +import "reflect-metadata"; +import { EducationResolver } from "../../../src/resolvers/education.resolver"; +import { prismaMock } from "../../singleton"; +import { EducationsResponse } from "../../../src/entities/response.types"; +import { Education as PrismaEducation } from "@prisma/client"; + +describe("EducationResolver - educationList", () => { + let resolver: EducationResolver; + + const mockEducations: PrismaEducation[] = [ + { + id: 1, + titleFR: "Diplôme A", + titleEN: "Degree A", + diplomaLevelFR: "Licence", + diplomaLevelEN: "Bachelor", + school: "University A", + location: "City A", + year: 2020, + startDateFR: "Sept 2017", + startDateEN: "Sep 2017", + endDateFR: "Juin 2020", + endDateEN: "Jun 2020", + month: 36, + typeEN: "University", + typeFR: "Université", + }, + { + id: 2, + titleFR: "Diplôme B", + titleEN: "Degree B", + diplomaLevelFR: "Master", + diplomaLevelEN: "Master", + school: "University B", + location: "City B", + year: 2022, + startDateFR: "Sept 2020", + startDateEN: "Sep 2020", + endDateFR: "Juin 2022", + endDateEN: "Jun 2022", + month: 24, + typeEN: "University", + typeFR: "Université", + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.education.findMany.mockReset(); + resolver = new EducationResolver(prismaMock); + }); + + it("should return a list of educations successfully", async () => { + prismaMock.education.findMany.mockResolvedValueOnce(mockEducations); + + const result: EducationsResponse = await resolver.educationList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Educations fetched"); + expect(result.educations).toEqual(mockEducations); + + expect(prismaMock.education.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findMany).toHaveBeenCalledWith(); + }); + + it("should return an empty list if no educations are found", async () => { + prismaMock.education.findMany.mockResolvedValueOnce([]); + + const result: EducationsResponse = await resolver.educationList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Educations fetched"); + expect(result.educations).toEqual([]); + + expect(prismaMock.education.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findMany).toHaveBeenCalledWith(); + }); + + it("should return 500 if there is a database error", async () => { + const errorMessage = "Database connection error"; + prismaMock.education.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationsResponse = await resolver.educationList(); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error fetching educations"); + expect(result.educations).toBeUndefined(); + + expect(prismaMock.education.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findMany).toHaveBeenCalledWith(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/education/updateEducation.test.ts b/backend/tests/resolvers/education/updateEducation.test.ts new file mode 100644 index 00000000..7835888a --- /dev/null +++ b/backend/tests/resolvers/education/updateEducation.test.ts @@ -0,0 +1,228 @@ +import "reflect-metadata"; +import { EducationResolver } from "../../../src/resolvers/education.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { UpdateEducationInput } from "../../../src/entities/inputs/education.input"; +import { EducationResponse } from "../../../src/entities/response.types"; +import { Education as PrismaEducation } from "@prisma/client"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("EducationResolver - updateEducation", () => { + let resolver: EducationResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockEditorUser: User = { + id: 3, + firstname: "Editor", + lastname: "User", + email: "editor@example.com", + role: UserRole.editor, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingEducation: PrismaEducation = { + id: 1, + titleFR: "Ancien Titre FR", + titleEN: "Old Title EN", + diplomaLevelFR: "Ancien Niveau Diplôme FR", + diplomaLevelEN: "Old Diploma Level EN", + school: "Old School", + location: "Old Location", + year: 2020, + startDateFR: "Janv 2018", + startDateEN: "Jan 2018", + endDateFR: "Juin 2020", + endDateEN: "June 2020", + month: 24, + typeEN: "Old Type EN", + typeFR: "Ancien Type FR", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.education.findUnique.mockReset(); + prismaMock.education.update.mockReset(); + resolver = new EducationResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully update an education record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateEducationInput = { + id: mockExistingEducation.id, + titleEN: "Updated Title EN", + school: "New School", + }; + const mockUpdatedEducation: PrismaEducation = { + ...mockExistingEducation, + ...updateInput, + }; + + prismaMock.education.findUnique.mockResolvedValueOnce(mockExistingEducation); + prismaMock.education.update.mockResolvedValueOnce(mockUpdatedEducation); + + const result: EducationResponse = await resolver.updateEducation(updateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Education updated"); + expect(result.education).toEqual(mockUpdatedEducation); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: updateInput.id } }); + expect(prismaMock.education.update).toHaveBeenCalledTimes(1); + expect(prismaMock.education.update).toHaveBeenCalledWith({ + where: { id: updateInput.id }, + data: { + titleFR: mockExistingEducation.titleFR, + titleEN: updateInput.titleEN, + diplomaLevelEN: mockExistingEducation.diplomaLevelEN, + diplomaLevelFR: mockExistingEducation.diplomaLevelFR, + school: updateInput.school, + location: mockExistingEducation.location, + year: mockExistingEducation.year, + startDateEN: mockExistingEducation.startDateEN, + startDateFR: mockExistingEducation.startDateFR, + endDateEN: mockExistingEducation.endDateEN, + endDateFR: mockExistingEducation.endDateFR, + month: mockExistingEducation.month, + typeEN: mockExistingEducation.typeEN, + typeFR: mockExistingEducation.typeFR, + }, + }); + }); + + it("should successfully update an education record by an editor user", async () => { + const editorContext: MyContext = { ...baseMockContext, user: mockEditorUser }; + const updateInput: UpdateEducationInput = { + id: mockExistingEducation.id, + titleFR: "Titre Mis à Jour FR", + location: "Nouvelle Localisation", + }; + const mockUpdatedEducation: PrismaEducation = { + ...mockExistingEducation, + ...updateInput, + }; + + prismaMock.education.findUnique.mockResolvedValueOnce(mockExistingEducation); + prismaMock.education.update.mockResolvedValueOnce(mockUpdatedEducation); + + const result: EducationResponse = await resolver.updateEducation(updateInput, editorContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Education updated"); + expect(result.education).toEqual(mockUpdatedEducation); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.update).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + const updateInput: UpdateEducationInput = { id: 1, titleEN: "Test" }; + + const result: EducationResponse = await resolver.updateEducation(updateInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.education.update).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin or editor", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + const updateInput: UpdateEducationInput = { id: 1, titleEN: "Test" }; + + const result: EducationResponse = await resolver.updateEducation(updateInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin or Editor role required."); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.education.update).not.toHaveBeenCalled(); + }); + + it("should return 404 if the education record is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateEducationInput = { id: 999, titleEN: "Non Existent" }; + + prismaMock.education.findUnique.mockResolvedValueOnce(null); + + const result: EducationResponse = await resolver.updateEducation(updateInput, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Education not found"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.findUnique).toHaveBeenCalledWith({ where: { id: updateInput.id } }); + expect(prismaMock.education.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during finding the education record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateEducationInput = { id: mockExistingEducation.id, titleEN: "Test" }; + const errorMessage = "DB error during findUnique"; + + prismaMock.education.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.updateEducation(updateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during updating the education record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateEducationInput = { id: mockExistingEducation.id, titleEN: "Test" }; + const errorMessage = "DB error during update"; + + prismaMock.education.findUnique.mockResolvedValueOnce(mockExistingEducation); + prismaMock.education.update.mockRejectedValueOnce(new Error(errorMessage)); + + const result: EducationResponse = await resolver.updateEducation(updateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating education"); + expect(result.education).toBeUndefined(); + + expect(prismaMock.education.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.education.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/experience/createExperience.test.ts b/backend/tests/resolvers/experience/createExperience.test.ts new file mode 100644 index 00000000..bfb17c04 --- /dev/null +++ b/backend/tests/resolvers/experience/createExperience.test.ts @@ -0,0 +1,124 @@ +import "reflect-metadata"; +import { ExperienceResolver } from "../../../src/resolvers/experience.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { CreateExperienceInput } from "../../../src/entities/inputs/experience.input"; +import { ExperienceResponse } from "../../../src/entities/response.types"; +import { Experience as PrismaExperience } from "@prisma/client"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("ExperienceResolver - createExperience", () => { + let resolver: ExperienceResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockCreateExperienceInput: CreateExperienceInput = { + jobFR: "Développeur Logiciel", + jobEN: "Software Developer", + business: "Tech Co", + employmentContractFR: "CDI", + employmentContractEN: "Permanent", + startDateFR: "Janvier 2020", + startDateEN: "January 2020", + endDateFR: "Juin 2023", + endDateEN: "June 2023", + month: 42, + typeFR: "Temps plein", + typeEN: "Full-time", + }; + + const mockCreatedExperience: PrismaExperience = { + id: 1, + ...mockCreateExperienceInput, + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.experience.create.mockReset(); + resolver = new ExperienceResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully create a new experience record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.experience.create.mockResolvedValueOnce(mockCreatedExperience); + + const result: ExperienceResponse = await resolver.createExperience(mockCreateExperienceInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experience created"); + expect(result.experience).toEqual(mockCreatedExperience); + + expect(prismaMock.experience.create).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.create).toHaveBeenCalledWith({ data: mockCreateExperienceInput }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: ExperienceResponse = await resolver.createExperience(mockCreateExperienceInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.create).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: ExperienceResponse = await resolver.createExperience(mockCreateExperienceInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during experience creation", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during experience creation"; + prismaMock.experience.create.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.createExperience(mockCreateExperienceInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error creating experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.create).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.create).toHaveBeenCalledWith({ data: mockCreateExperienceInput }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/experience/deleteExperience.test.ts b/backend/tests/resolvers/experience/deleteExperience.test.ts new file mode 100644 index 00000000..a0a14e3b --- /dev/null +++ b/backend/tests/resolvers/experience/deleteExperience.test.ts @@ -0,0 +1,156 @@ +import "reflect-metadata"; +import { ExperienceResolver } from "../../../src/resolvers/experience.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { ExperienceResponse } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("ExperienceResolver - deleteExperience", () => { + let resolver: ExperienceResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingExperience = { + id: 1, + jobFR: "Poste à Supprimer", + jobEN: "Job to Delete", + business: "Business to Delete", + employmentContractFR: "CDI", + employmentContractEN: "Permanent", + startDateFR: "Janvier 2020", + startDateEN: "January 2020", + endDateFR: "Juin 2023", + endDateEN: "June 2023", + month: 42, + typeFR: "Temps plein", + typeEN: "Full-time", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.experience.findUnique.mockReset(); + prismaMock.experience.delete.mockReset(); + resolver = new ExperienceResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully delete an experience record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExistingExperience); + prismaMock.experience.delete.mockResolvedValueOnce(mockExistingExperience); + + const result: ExperienceResponse = await resolver.deleteExperience(mockExistingExperience.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experience deleted"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingExperience.id } }); + expect(prismaMock.experience.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.delete).toHaveBeenCalledWith({ where: { id: mockExistingExperience.id } }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: ExperienceResponse = await resolver.deleteExperience(mockExistingExperience.id, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.experience.delete).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: ExperienceResponse = await resolver.deleteExperience(mockExistingExperience.id, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.experience.delete).not.toHaveBeenCalled(); + }); + + it("should return 404 if the experience record to delete is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.experience.findUnique.mockResolvedValueOnce(null); + + const result: ExperienceResponse = await resolver.deleteExperience(999, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Experience not found"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + expect(prismaMock.experience.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during finding the experience record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during findUnique"; + prismaMock.experience.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.deleteExperience(mockExistingExperience.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during deleting the experience record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during delete"; + + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExistingExperience); + prismaMock.experience.delete.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.deleteExperience(mockExistingExperience.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.delete).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/experience/experienceById.test.ts b/backend/tests/resolvers/experience/experienceById.test.ts new file mode 100644 index 00000000..52351be9 --- /dev/null +++ b/backend/tests/resolvers/experience/experienceById.test.ts @@ -0,0 +1,71 @@ +import "reflect-metadata"; +import { ExperienceResolver } from "../../../src/resolvers/experience.resolver"; +import { prismaMock } from "../../singleton"; +import { ExperienceResponse } from "../../../src/entities/response.types"; +import { Experience as PrismaExperience } from "@prisma/client"; + +describe("ExperienceResolver - experienceById", () => { + let resolver: ExperienceResolver; + + const mockExperience: PrismaExperience = { + id: 1, + jobFR: "Développeur Senior", + jobEN: "Senior Developer", + business: "Global Tech Solutions", + employmentContractFR: "CDI", + employmentContractEN: "Permanent", + startDateFR: "Janvier 2018", + startDateEN: "January 2018", + endDateFR: "Décembre 2023", + endDateEN: "December 2023", + month: 72, + typeFR: "Temps plein", + typeEN: "Full-time", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.experience.findUnique.mockReset(); + resolver = new ExperienceResolver(prismaMock); + }); + + it("should return an experience record by ID successfully", async () => { + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExperience); + + const result: ExperienceResponse = await resolver.experienceById(mockExperience.id); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experience fetched"); + expect(result.experience).toEqual(mockExperience); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: mockExperience.id } }); + }); + + it("should return 404 if the experience record is not found", async () => { + prismaMock.experience.findUnique.mockResolvedValueOnce(null); + + const result: ExperienceResponse = await resolver.experienceById(999); + + expect(result.code).toBe(404); + expect(result.message).toBe("Experience not found"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + }); + + it("should return 500 for an internal server error", async () => { + const errorMessage = "Database query failed unexpectedly"; + prismaMock.experience.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.experienceById(mockExperience.id); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error fetching experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: mockExperience.id } }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/experience/experienceList.test.ts b/backend/tests/resolvers/experience/experienceList.test.ts new file mode 100644 index 00000000..29000351 --- /dev/null +++ b/backend/tests/resolvers/experience/experienceList.test.ts @@ -0,0 +1,88 @@ +import "reflect-metadata"; +import { ExperienceResolver } from "../../../src/resolvers/experience.resolver"; +import { prismaMock } from "../../singleton"; +import { ExperiencesResponse } from "../../../src/entities/response.types"; +import { Experience as PrismaExperience } from "@prisma/client"; + +describe("ExperienceResolver - experienceList", () => { + let resolver: ExperienceResolver; + + const mockExperiences: PrismaExperience[] = [ + { + id: 1, + jobFR: "Développeur Fullstack", + jobEN: "Fullstack Developer", + business: "AwesomeTech", + employmentContractFR: "CDI", + employmentContractEN: "Permanent Contract", + startDateFR: "Mars 2022", + startDateEN: "March 2022", + endDateFR: "Présent", + endDateEN: "Present", + month: 27, + typeFR: "À distance", + typeEN: "Remote", + }, + { + id: 2, + jobFR: "Développeur Front-end Junior", + jobEN: "Junior Front-end Developer", + business: "Startup Innov", + employmentContractFR: "Stage", + employmentContractEN: "Internship", + startDateFR: "Septembre 2021", + startDateEN: "September 2021", + endDateFR: "Février 2022", + endDateEN: "February 2022", + month: 6, + typeFR: "Sur site", + typeEN: "On-site", + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.experience.findMany.mockReset(); + resolver = new ExperienceResolver(prismaMock); + }); + + it("should return a list of experiences successfully", async () => { + prismaMock.experience.findMany.mockResolvedValueOnce(mockExperiences); + + const result: ExperiencesResponse = await resolver.experienceList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experiences fetched"); + expect(result.experiences).toEqual(mockExperiences); + + expect(prismaMock.experience.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findMany).toHaveBeenCalledWith(); + }); + + it("should return an empty list if no experiences are found", async () => { + prismaMock.experience.findMany.mockResolvedValueOnce([]); + + const result: ExperiencesResponse = await resolver.experienceList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experiences fetched"); + expect(result.experiences).toEqual([]); + + expect(prismaMock.experience.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findMany).toHaveBeenCalledWith(); + }); + + it("should return 500 if there is a database error", async () => { + const errorMessage = "Database connection error during fetch"; + prismaMock.experience.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperiencesResponse = await resolver.experienceList(); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error fetching experiences"); + expect(result.experiences).toBeUndefined(); + + expect(prismaMock.experience.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findMany).toHaveBeenCalledWith(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/experience/updateExperience.test.ts b/backend/tests/resolvers/experience/updateExperience.test.ts new file mode 100644 index 00000000..dabc7566 --- /dev/null +++ b/backend/tests/resolvers/experience/updateExperience.test.ts @@ -0,0 +1,224 @@ +import "reflect-metadata"; +import { ExperienceResolver } from "../../../src/resolvers/experience.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { UpdateExperienceInput } from "../../../src/entities/inputs/experience.input"; +import { ExperienceResponse } from "../../../src/entities/response.types"; +import { Experience as PrismaExperience } from "@prisma/client"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("ExperienceResolver - updateExperience", () => { + let resolver: ExperienceResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockEditorUser: User = { + id: 3, + firstname: "Editor", + lastname: "User", + email: "editor@example.com", + role: UserRole.editor, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingExperience: PrismaExperience = { + id: 1, + jobFR: "Ancien Poste FR", + jobEN: "Old Job EN", + business: "Ancienne Entreprise", + employmentContractFR: "Ancien Contrat FR", + employmentContractEN: "Old Contract EN", + startDateFR: "Janvier 2020", + startDateEN: "January 2020", + endDateFR: "Juin 2023", + endDateEN: "June 2023", + month: 36, + typeFR: "CDI", + typeEN: "Full-time", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.experience.findUnique.mockReset(); + prismaMock.experience.update.mockReset(); + resolver = new ExperienceResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully update an experience record by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateExperienceInput = { + id: mockExistingExperience.id, + jobEN: "Updated Job EN", + business: "New Business Inc.", + }; + const mockUpdatedExperience: PrismaExperience = { + ...mockExistingExperience, + ...updateInput, + }; + + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExistingExperience); + prismaMock.experience.update.mockResolvedValueOnce(mockUpdatedExperience); + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experience updated"); + expect(result.experience).toEqual(mockUpdatedExperience); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: updateInput.id } }); + expect(prismaMock.experience.update).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.update).toHaveBeenCalledWith({ + where: { id: updateInput.id }, + data: { + jobEN: updateInput.jobEN, + jobFR: mockExistingExperience.jobFR, + business: updateInput.business, + employmentContractEN: mockExistingExperience.employmentContractEN, + employmentContractFR: mockExistingExperience.employmentContractFR, + startDateEN: mockExistingExperience.startDateEN, + startDateFR: mockExistingExperience.startDateFR, + endDateEN: mockExistingExperience.endDateEN, + endDateFR: mockExistingExperience.endDateFR, + month: mockExistingExperience.month, + typeEN: mockExistingExperience.typeEN, + typeFR: mockExistingExperience.typeFR, + }, + }); + }); + + it("should successfully update an experience record by an editor user", async () => { + const editorContext: MyContext = { ...baseMockContext, user: mockEditorUser }; + const updateInput: UpdateExperienceInput = { + id: mockExistingExperience.id, + jobFR: "Nouveau Poste FR", + typeEN: "Part-time", + }; + const mockUpdatedExperience: PrismaExperience = { + ...mockExistingExperience, + ...updateInput, + }; + + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExistingExperience); + prismaMock.experience.update.mockResolvedValueOnce(mockUpdatedExperience); + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, editorContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Experience updated"); + expect(result.experience).toEqual(mockUpdatedExperience); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.update).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + const updateInput: UpdateExperienceInput = { id: 1, jobEN: "Test" }; + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.experience.update).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin or editor", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + const updateInput: UpdateExperienceInput = { id: 1, jobEN: "Test" }; + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin or Editor role required."); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.experience.update).not.toHaveBeenCalled(); + }); + + it("should return 404 if the experience record is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateExperienceInput = { id: 999, jobEN: "Non Existent" }; + + prismaMock.experience.findUnique.mockResolvedValueOnce(null); + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Experience not found"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.findUnique).toHaveBeenCalledWith({ where: { id: updateInput.id } }); + expect(prismaMock.experience.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during finding the experience record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateExperienceInput = { id: mockExistingExperience.id, jobEN: "Test" }; + const errorMessage = "DB error during findUnique"; + + prismaMock.experience.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during updating the experience record", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const updateInput: UpdateExperienceInput = { id: mockExistingExperience.id, jobEN: "Test" }; + const errorMessage = "DB error during update"; + + prismaMock.experience.findUnique.mockResolvedValueOnce(mockExistingExperience); + prismaMock.experience.update.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ExperienceResponse = await resolver.updateExperience(updateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating experience"); + expect(result.experience).toBeUndefined(); + + expect(prismaMock.experience.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.experience.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/project/createProject.test.ts b/backend/tests/resolvers/project/createProject.test.ts new file mode 100644 index 00000000..929bf0c7 --- /dev/null +++ b/backend/tests/resolvers/project/createProject.test.ts @@ -0,0 +1,273 @@ +import "reflect-metadata"; +import { ProjectResolver } from "../../../src/resolvers/project.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { CreateProjectInput } from "../../../src/entities/inputs/project.input"; +import { ProjectResponse } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { Project as PrismaProject, Skill as PrismaSkill, ProjectSkill as PrismaProjectSkill } from "@prisma/client"; + +describe("ProjectResolver - createProject", () => { + let resolver: ProjectResolver; + + const mockCookies = jest.mocked(new Cookies({} as any, {} as any)); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingSkills: PrismaSkill[] = [ + { id: 1, name: "React", image: "react.png", categoryId: 10 }, + { id: 2, name: "Node.js", image: "node.png", categoryId: 11 }, + { id: 3, name: "TypeScript", image: "ts.png", categoryId: 10 }, + ]; + + const mockCreateProjectInput: CreateProjectInput = { + title: "New Portfolio Project", + descriptionEN: "A fantastic new project in English.", + descriptionFR: "Un nouveau projet fantastique en français.", + typeDisplay: "Web Application", + github: "https://github.com/newproject", + contentDisplay: "Some content about the project.", + skillIds: [1, 2], + }; + + const mockCreatedProject: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + id: 1, + title: mockCreateProjectInput.title, + descriptionEN: mockCreateProjectInput.descriptionEN, + descriptionFR: mockCreateProjectInput.descriptionFR, + typeDisplay: mockCreateProjectInput.typeDisplay, + github: mockCreateProjectInput.github ?? "", + contentDisplay: mockCreateProjectInput.contentDisplay, + skills: [ + { + projectId: 1, + skillId: 1, + skill: mockExistingSkills[0], + }, + { + projectId: 1, + skillId: 2, + skill: mockExistingSkills[1], + }, + ], + }; + + const expectedProjectResponse = { + id: mockCreatedProject.id, + title: mockCreatedProject.title, + descriptionEN: mockCreatedProject.descriptionEN, + descriptionFR: mockCreatedProject.descriptionFR, + typeDisplay: mockCreatedProject.typeDisplay, + github: mockCreatedProject.github, + contentDisplay: mockCreatedProject.contentDisplay, + skills: [ + { id: mockExistingSkills[0].id, name: mockExistingSkills[0].name, image: mockExistingSkills[0].image, categoryId: mockExistingSkills[0].categoryId }, + { id: mockExistingSkills[1].id, name: mockExistingSkills[1].name, image: mockExistingSkills[1].image, categoryId: mockExistingSkills[1].categoryId }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.skill.findMany.mockReset(); + prismaMock.project.create.mockReset(); + resolver = new ProjectResolver(prismaMock); + }); + + it("should successfully create a new project with skills by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skill.findMany.mockResolvedValueOnce(mockExistingSkills.filter(s => mockCreateProjectInput.skillIds.includes(s.id))); + + prismaMock.project.create.mockResolvedValueOnce(mockCreatedProject); + + const result: ProjectResponse = await resolver.createProject(mockCreateProjectInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project created successfully"); + expect(result.project).toEqual(expectedProjectResponse); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ + where: { id: { in: mockCreateProjectInput.skillIds } }, + }); + + expect(prismaMock.project.create).toHaveBeenCalledTimes(1); + expect(prismaMock.project.create).toHaveBeenCalledWith({ + data: { + title: mockCreateProjectInput.title, + descriptionEN: mockCreateProjectInput.descriptionEN, + descriptionFR: mockCreateProjectInput.descriptionFR, + typeDisplay: mockCreateProjectInput.typeDisplay, + github: mockCreateProjectInput.github, + contentDisplay: mockCreateProjectInput.contentDisplay, + skills: { + create: mockCreateProjectInput.skillIds.map((skillId) => ({ + skill: { connect: { id: skillId } }, + })), + }, + }, + include: { + skills: { include: { skill: true } }, + }, + }); + }); + + it("should successfully create a new project with no skills by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const inputWithoutSkills: CreateProjectInput = { + ...mockCreateProjectInput, + skillIds: [], + }; + + const createdProjectWithoutSkills: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + ...mockCreatedProject, + skills: [], + }; + + const expectedResponseWithoutSkills = { + ...expectedProjectResponse, + skills: [], + }; + + prismaMock.skill.findMany.mockResolvedValueOnce([]); + prismaMock.project.create.mockResolvedValueOnce(createdProjectWithoutSkills); + + const result: ProjectResponse = await resolver.createProject(inputWithoutSkills, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project created successfully"); + expect(result.project).toEqual(expectedResponseWithoutSkills); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ + where: { id: { in: [] } }, + }); + + expect(prismaMock.project.create).toHaveBeenCalledTimes(1); + expect(prismaMock.project.create).toHaveBeenCalledWith({ + data: { + title: inputWithoutSkills.title, + descriptionEN: inputWithoutSkills.descriptionEN, + descriptionFR: inputWithoutSkills.descriptionFR, + typeDisplay: inputWithoutSkills.typeDisplay, + github: inputWithoutSkills.github, + contentDisplay: inputWithoutSkills.contentDisplay, + skills: { create: [] }, + }, + include: { + skills: { include: { skill: true } }, + }, + }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: ProjectResponse = await resolver.createProject(mockCreateProjectInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.project.create).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: ProjectResponse = await resolver.createProject(mockCreateProjectInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.project.create).not.toHaveBeenCalled(); + }); + + it("should return 400 if one or more skill IDs are invalid", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const inputWithInvalidSkill: CreateProjectInput = { + ...mockCreateProjectInput, + skillIds: [1, 999], + }; + + prismaMock.skill.findMany.mockResolvedValueOnce(mockExistingSkills.filter(s => s.id === 1)); + + const result: ProjectResponse = await resolver.createProject(inputWithInvalidSkill, adminContext); + + expect(result.code).toBe(400); + expect(result.message).toBe("One or more skill IDs are invalid."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ + where: { id: { in: inputWithInvalidSkill.skillIds } }, + }); + expect(prismaMock.project.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during skill validation (findMany)", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill lookup"; + + prismaMock.skill.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.createProject(mockCreateProjectInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during project creation", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during project creation"; + + prismaMock.skill.findMany.mockResolvedValueOnce(mockExistingSkills.filter(s => mockCreateProjectInput.skillIds.includes(s.id))); + prismaMock.project.create.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.createProject(mockCreateProjectInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.create).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/project/deleteProject.test.ts b/backend/tests/resolvers/project/deleteProject.test.ts new file mode 100644 index 00000000..66ac4d79 --- /dev/null +++ b/backend/tests/resolvers/project/deleteProject.test.ts @@ -0,0 +1,171 @@ +import "reflect-metadata"; +import { ProjectResolver } from "../../../src/resolvers/project.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { Response } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("ProjectResolver - deleteProject", () => { + let resolver: ProjectResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingProject = { + id: 100, + title: "Project to Delete", + descriptionEN: "Desc EN", + descriptionFR: "Desc FR", + typeDisplay: "Type", + github: "github.com", + contentDisplay: "Content", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.project.findUnique.mockReset(); + prismaMock.projectSkill.deleteMany.mockReset(); + prismaMock.project.delete.mockReset(); + resolver = new ProjectResolver(prismaMock); + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully delete a project and its associated skills by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 3 }); + prismaMock.project.delete.mockResolvedValueOnce(mockExistingProject); + + const result: Response = await resolver.deleteProject(mockExistingProject.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project deleted successfully"); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingProject.id } }); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledWith({ where: { projectId: mockExistingProject.id } }); + expect(prismaMock.project.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.project.delete).toHaveBeenCalledWith({ where: { id: mockExistingProject.id } }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: Response = await resolver.deleteProject(mockExistingProject.id, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + + expect(prismaMock.project.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.delete).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: Response = await resolver.deleteProject(mockExistingProject.id, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + + expect(prismaMock.project.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.delete).not.toHaveBeenCalled(); + }); + + it("should return 404 if the project to delete is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.project.findUnique.mockResolvedValueOnce(null); + + const result: Response = await resolver.deleteProject(999, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Project not found"); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during project lookup", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during findUnique"; + prismaMock.project.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: Response = await resolver.deleteProject(mockExistingProject.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during projectSkill deletion", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during projectSkill deleteMany"; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.projectSkill.deleteMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: Response = await resolver.deleteProject(mockExistingProject.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during project deletion", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during project delete"; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 1 }); + prismaMock.project.delete.mockRejectedValueOnce(new Error(errorMessage)); + + const result: Response = await resolver.deleteProject(mockExistingProject.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.delete).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/project/projectById.test.ts b/backend/tests/resolvers/project/projectById.test.ts new file mode 100644 index 00000000..c0761224 --- /dev/null +++ b/backend/tests/resolvers/project/projectById.test.ts @@ -0,0 +1,101 @@ +import "reflect-metadata"; +import { ProjectResolver } from "../../../src/resolvers/project.resolver"; +import { prismaMock } from "../../singleton"; +import { ProjectResponse } from "../../../src/entities/response.types"; +import { Project as PrismaProject, Skill as PrismaSkill, ProjectSkill as PrismaProjectSkill } from "@prisma/client"; + +describe("ProjectResolver - projectById", () => { + let resolver: ProjectResolver; + + const mockExistingProjectWithSkills: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + id: 1, + title: "My Awesome Project", + descriptionEN: "A project to showcase skills.", + descriptionFR: "Un projet pour montrer les compétences.", + typeDisplay: "Web App", + github: "https://github.com/myawesomeproject", + contentDisplay: "Detailed content about the project.", + skills: [ + { + projectId: 1, + skillId: 101, + skill: { id: 101, name: "React", image: "react.png", categoryId: 1 }, + }, + { + projectId: 1, + skillId: 102, + skill: { id: 102, name: "TypeScript", image: "typescript.png", categoryId: 1 }, + }, + ], + }; + + const expectedMappedProject = { + id: mockExistingProjectWithSkills.id, + title: mockExistingProjectWithSkills.title, + descriptionEN: mockExistingProjectWithSkills.descriptionEN, + descriptionFR: mockExistingProjectWithSkills.descriptionFR, + typeDisplay: mockExistingProjectWithSkills.typeDisplay, + github: mockExistingProjectWithSkills.github, + contentDisplay: mockExistingProjectWithSkills.contentDisplay, + skills: [ + { id: 101, name: "React", image: "react.png", categoryId: 1 }, + { id: 102, name: "TypeScript", image: "typescript.png", categoryId: 1 }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.project.findUnique.mockReset(); + resolver = new ProjectResolver(prismaMock); + }); + + it("should return a project by ID with its associated skills successfully", async () => { + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProjectWithSkills); + + const result: ProjectResponse = await resolver.projectById(mockExistingProjectWithSkills.id); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project found"); + expect(result.project).toBeDefined(); + expect(result.project).toEqual(expectedMappedProject); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ + where: { id: mockExistingProjectWithSkills.id }, + include: { skills: { include: { skill: true } } }, + }); + }); + + it("should return 404 if the project is not found", async () => { + prismaMock.project.findUnique.mockResolvedValueOnce(null); + + const result: ProjectResponse = await resolver.projectById(999); + + expect(result.code).toBe(404); + expect(result.message).toBe("Project not found"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ + where: { id: 999 }, + include: { skills: { include: { skill: true } } }, + }); + }); + + it("should return 500 for an internal server error", async () => { + const errorMessage = "Database query failed"; + prismaMock.project.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.projectById(mockExistingProjectWithSkills.id); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ + where: { id: mockExistingProjectWithSkills.id }, + include: { skills: { include: { skill: true } } }, + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/project/projectList.test.ts b/backend/tests/resolvers/project/projectList.test.ts new file mode 100644 index 00000000..30cde717 --- /dev/null +++ b/backend/tests/resolvers/project/projectList.test.ts @@ -0,0 +1,134 @@ +import "reflect-metadata"; +import { ProjectResolver } from "../../../src/resolvers/project.resolver"; +import { prismaMock } from "../../singleton"; +import { ProjectsResponse } from "../../../src/entities/response.types"; +import { Project as PrismaProject, Skill as PrismaSkill, ProjectSkill as PrismaProjectSkill } from "@prisma/client"; + +describe("ProjectResolver - projectList", () => { + let resolver: ProjectResolver; + + const mockProjectsWithSkills: (PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] })[] = [ + { + id: 2, + title: "Project Alpha", + descriptionEN: "Description Alpha EN", + descriptionFR: "Description Alpha FR", + typeDisplay: "Web", + github: "https://github.com/alpha", + contentDisplay: "Content Alpha", + skills: [ + { projectId: 2, skillId: 101, skill: { id: 101, name: "React", image: "react.png", categoryId: 1 } }, + { projectId: 2, skillId: 102, skill: { id: 102, name: "Node.js", image: "node.png", categoryId: 2 } }, + ], + }, + { + id: 1, + title: "Project Beta", + descriptionEN: "Description Beta EN", + descriptionFR: "Description Beta FR", + typeDisplay: "Mobile", + github: null, + contentDisplay: "Content Beta", + skills: [ + { projectId: 1, skillId: 103, skill: { id: 103, name: "Swift", image: "swift.png", categoryId: 3 } }, + ], + }, + ]; + + const expectedMappedProjects = [ + { + id: 2, + title: "Project Alpha", + descriptionEN: "Description Alpha EN", + descriptionFR: "Description Alpha FR", + typeDisplay: "Web", + github: "https://github.com/alpha", + contentDisplay: "Content Alpha", + skills: [ + { id: 101, name: "React", image: "react.png", categoryId: 1 }, + { id: 102, name: "Node.js", image: "node.png", categoryId: 2 }, + ], + }, + { + id: 1, + title: "Project Beta", + descriptionEN: "Description Beta EN", + descriptionFR: "Description Beta FR", + typeDisplay: "Mobile", + github: null, + contentDisplay: "Content Beta", + skills: [ + { id: 103, name: "Swift", image: "swift.png", categoryId: 3 }, + ], + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.project.findMany.mockReset(); + resolver = new ProjectResolver(prismaMock); + }); + + it("should return all projects with their associated skills successfully", async () => { + prismaMock.project.findMany.mockResolvedValueOnce(mockProjectsWithSkills); + + const result: ProjectsResponse = await resolver.projectList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Projects fetched successfully"); + expect(result.projects).toBeDefined(); + expect(result.projects).toEqual(expectedMappedProjects); + + expect(prismaMock.project.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findMany).toHaveBeenCalledWith({ + include: { + skills: { + include: { skill: true }, + }, + }, + orderBy: { id: "desc" }, + }); + }); + + it("should return an empty array if no projects are found", async () => { + prismaMock.project.findMany.mockResolvedValueOnce([]); + + const result: ProjectsResponse = await resolver.projectList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Projects fetched successfully"); + expect(result.projects).toBeDefined(); + expect(result.projects).toEqual([]); + + expect(prismaMock.project.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findMany).toHaveBeenCalledWith({ + include: { + skills: { + include: { skill: true }, + }, + }, + orderBy: { id: "desc" }, + }); + }); + + it("should return a 500 error if fetching projects fails", async () => { + const errorMessage = "Database connection error"; + prismaMock.project.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectsResponse = await resolver.projectList(); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.projects).toBeUndefined(); + + expect(prismaMock.project.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findMany).toHaveBeenCalledWith({ + include: { + skills: { + include: { skill: true }, + }, + }, + orderBy: { id: "desc" }, + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/project/updateProject.test.ts b/backend/tests/resolvers/project/updateProject.test.ts new file mode 100644 index 00000000..b4be6a6b --- /dev/null +++ b/backend/tests/resolvers/project/updateProject.test.ts @@ -0,0 +1,452 @@ +import "reflect-metadata"; +import { ProjectResolver } from "../../../src/resolvers/project.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { UpdateProjectInput } from "../../../src/entities/inputs/project.input"; // Adjust path if needed +import { ProjectResponse } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { Project as PrismaProject, Skill as PrismaSkill, ProjectSkill as PrismaProjectSkill } from "@prisma/client"; +import { mockDeep } from 'jest-mock-extended'; + +describe("ProjectResolver - updateProject", () => { + let resolver: ProjectResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockEditorUser: User = { + id: 3, + firstname: "Editor", + lastname: "User", + email: "editor@example.com", + role: UserRole.editor, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingProject: PrismaProject & { skills: PrismaProjectSkill[] } = { + id: 100, + title: "Old Project Title", + descriptionEN: "Old English description.", + descriptionFR: "Ancienne description française.", + typeDisplay: "Old Type", + github: "https://github.com/oldproject", + contentDisplay: "Old project content.", + skills: [{ projectId: 100, skillId: 1 }, { projectId: 100, skillId: 2}], + }; + + const mockAvailableSkills: PrismaSkill[] = [ + { id: 1, name: "React", image: "react.png", categoryId: 10 }, + { id: 2, name: "Node.js", image: "node.png", categoryId: 11 }, + { id: 3, name: "TypeScript", image: "ts.png", categoryId: 10 }, + ]; + + const createSkillDto = (skill: PrismaSkill) => ({ + id: skill.id, + name: skill.name, + image: skill.image, + categoryId: skill.categoryId, + }); + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.project.findUnique.mockReset(); + prismaMock.project.update.mockReset(); + prismaMock.skill.findMany.mockReset(); + prismaMock.projectSkill.deleteMany.mockReset(); + + resolver = new ProjectResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully update a project with all fields (including skillIds) by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + title: "Updated Project Title", + descriptionEN: "New English description.", + descriptionFR: "Nouvelle description française.", + typeDisplay: "Updated Type", + github: "https://github.com/updatedproject", + contentDisplay: "Updated content.", + skillIds: [1, 3], + }; + + const validSkillsForInput = mockAvailableSkills.filter(s => updateInput.skillIds!.includes(s.id)); + + const mockUpdatedProject: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + ...mockExistingProject, + ...updateInput, + skills: [ + { projectId: mockExistingProject.id, skillId: 1, skill: mockAvailableSkills[0] }, + { projectId: mockExistingProject.id, skillId: 3, skill: mockAvailableSkills[2] }, + ], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(validSkillsForInput); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 2 }); + prismaMock.project.update.mockResolvedValueOnce(mockUpdatedProject); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project updated successfully"); + expect(result.project).toBeDefined(); + expect(result.project!.id).toBe(mockExistingProject.id); + expect(result.project!.title).toBe(updateInput.title); + expect(result.project!.descriptionEN).toBe(updateInput.descriptionEN); + expect(result.project!.skills.length).toBe(2); + expect(result.project!.skills[0]).toEqual(createSkillDto(mockAvailableSkills[0])); + expect(result.project!.skills[1]).toEqual(createSkillDto(mockAvailableSkills[2])); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ + where: { id: updateInput.id }, + include: { skills: true }, + }); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ where: { id: { in: updateInput.skillIds } } }); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledWith({ where: { projectId: updateInput.id } }); + expect(prismaMock.project.update).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).toHaveBeenCalledWith({ + where: { id: updateInput.id }, + data: { + ...updateInput, + id: undefined, + skillIds: undefined, + skills: { + create: updateInput.skillIds!.map(skillId => ({ skill: { connect: { id: skillId } } })) + }, + }, + include: { skills: { include: { skill: true } } }, + }); + }); + + it("should successfully update a project with all fields (including skillIds) by an editor user", async () => { + const editorContext: MyContext = { ...baseMockContext, user: mockEditorUser }; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + title: "Editor Updated Project Title", + descriptionEN: "Editor updated English description.", + skillIds: [1], + }; + + const validSkillsForInput = mockAvailableSkills.filter(s => updateInput.skillIds!.includes(s.id)); + const mockUpdatedProject: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + ...mockExistingProject, + ...updateInput, + skills: [{ projectId: mockExistingProject.id, skillId: 1, skill: mockAvailableSkills[0] }], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(validSkillsForInput); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 2 }); + prismaMock.project.update.mockResolvedValueOnce(mockUpdatedProject); + + const result: ProjectResponse = await resolver.updateProject(updateInput, editorContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project updated successfully"); + expect(result.project).toBeDefined(); + expect(result.project!.title).toBe(updateInput.title); + expect(result.project!.skills.length).toBe(1); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).toHaveBeenCalledTimes(1); + }); + + it("should successfully update project fields without changing skills by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + title: "Title Only Updated", + descriptionFR: "Description FR updated.", + }; + + const mockUpdatedProject: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + ...mockExistingProject, + ...updateInput, + skills: mockExistingProject.skills.map(ps => ({ + ...ps, skill: mockAvailableSkills.find(s => s.id === ps.skillId)! + })), + }; + + prismaMock.project.findUnique.mockResolvedValueOnce({ + ...mockExistingProject, + }); + prismaMock.project.update.mockResolvedValueOnce(mockUpdatedProject); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project updated successfully"); + expect(result.project!.title).toBe(updateInput.title); + expect(result.project!.descriptionFR).toBe(updateInput.descriptionFR); + + expect(result.project!.skills.length).toBe(2); + expect(result.project!.skills[0].id).toBe(mockExistingProject.skills[0].skillId); + expect(result.project!.skills[1].id).toBe(mockExistingProject.skills[1].skillId); + + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).toHaveBeenCalledWith({ + where: { id: updateInput.id }, + data: { + ...updateInput, + id: undefined, + skills: undefined, + }, + include: { skills: { include: { skill: true } } }, + }); + }); + + it("should successfully update only project skills by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + skillIds: [3], + }; + + const validSkillsForInput = mockAvailableSkills.filter(s => updateInput.skillIds!.includes(s.id)); + const mockUpdatedProject: PrismaProject & { skills: (PrismaProjectSkill & { skill: PrismaSkill })[] } = { + ...mockExistingProject, + skills: [ + { projectId: mockExistingProject.id, skillId: 3, skill: mockAvailableSkills[2] }, + ], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(validSkillsForInput); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 2 }); + prismaMock.project.update.mockResolvedValueOnce(mockUpdatedProject); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Project updated successfully"); + expect(result.project!.skills.length).toBe(1); + expect(result.project!.skills[0]).toEqual(createSkillDto(mockAvailableSkills[2])); + + expect(result.project!.title).toBe(mockExistingProject.title); + + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated", async () => { + + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result: ProjectResponse = await resolver.updateProject({ id: 1, title: "Test" }, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin or editor", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result: ProjectResponse = await resolver.updateProject({ id: 1, title: "Test" }, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin or Editor role required."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 404 if the project to update is not found", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.project.findUnique.mockResolvedValueOnce(null); + + const result: ProjectResponse = await resolver.updateProject({ id: 999, title: "Non Existent" }, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Project not found"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.project.findUnique).toHaveBeenCalledWith({ + where: { id: 999 }, + include: { skills: true }, + }); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 400 if one or more skill IDs are invalid when updating skills", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + skillIds: [1, 999], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(mockAvailableSkills.filter(s => s.id === 1)); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + expect(result.code).toBe(400); + expect(result.message).toBe("One or more skill IDs are invalid."); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ where: { id: { in: updateInput.skillIds } } }); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during initial project lookup (findUnique)", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during project findUnique"; + + prismaMock.project.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.updateProject({ id: mockExistingProject.id, title: "Test" }, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during skill validation (findMany)", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during skill findMany"; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + skillIds: [1, 2], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during projectSkill deletion (deleteMany)", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during projectSkill deleteMany"; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + skillIds: [1, 2], + }; + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(mockAvailableSkills.filter(s => updateInput.skillIds!.includes(s.id))); + prismaMock.projectSkill.deleteMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for a database error during project update (update)", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "DB error during project update"; + + const updateInput: UpdateProjectInput = { + id: mockExistingProject.id, + skillIds: [1, 2], + }; + + + prismaMock.project.findUnique.mockResolvedValueOnce(mockExistingProject); + prismaMock.skill.findMany.mockResolvedValueOnce(mockAvailableSkills.filter(s => updateInput.skillIds!.includes(s.id))); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 2 }); + prismaMock.project.update.mockRejectedValueOnce(new Error(errorMessage)); + + const result: ProjectResponse = await resolver.updateProject(updateInput, adminContext); + + + expect(result.code).toBe(500); + expect(result.message).toBe("Internal server error"); + expect(result.project).toBeUndefined(); + + expect(prismaMock.project.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.project.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/createCategory.test.ts b/backend/tests/resolvers/skill/createCategory.test.ts new file mode 100644 index 00000000..a315ce42 --- /dev/null +++ b/backend/tests/resolvers/skill/createCategory.test.ts @@ -0,0 +1,157 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; // Ajuste le chemin si nécessaire +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { CreateCategoryInput } from "../../../src/entities/inputs/skill.input"; // Ajuste le chemin vers ton input type +import { CategoryResponse } from "../../../src/entities/response.types"; // Ajuste le chemin vers ton response type +import { Skill } from "../../../src/entities/skill.entity"; // Assumant que c'est ton DTO Skill pour les catégories +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - createCategory", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + + const createCategoryInput: CreateCategoryInput = { + categoryEN: "New English Category", + categoryFR: "Nouvelle Catégorie Française", + }; + + + const mockCreatedCategory = { + id: 100, + categoryEN: "New English Category", + categoryFR: "Nouvelle Catégorie Française", + }; + + beforeEach(() => { + + jest.clearAllMocks(); + prismaMock.skillCategory.create.mockReset(); + + + resolver = new SkillResolver(prismaMock); + + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + + + it("should successfully create a new category for an authenticated admin user", async () => { + + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + + prismaMock.skillCategory.create.mockResolvedValueOnce(mockCreatedCategory); + + + const result = await resolver.createCategory(createCategoryInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Category created successfully"); + expect(result.categories).toBeDefined(); + expect(result.categories?.length).toBe(1); + expect(result.categories?.[0]).toEqual({ + id: mockCreatedCategory.id, + categoryEN: mockCreatedCategory.categoryEN, + categoryFR: mockCreatedCategory.categoryFR, + skills: [], + }); + + expect(prismaMock.skillCategory.create).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.create).toHaveBeenCalledWith({ + data: { + categoryEN: createCategoryInput.categoryEN, + categoryFR: createCategoryInput.categoryFR, + }, + }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { + ...baseMockContext, + user: null, + }; + + const result = await resolver.createCategory(createCategoryInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.create).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { + ...baseMockContext, + user: mockRegularUser, + }; + + const result = await resolver.createCategory(createCategoryInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category creation", async () => { + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + + const errorMessage = "Database error during category creation"; + prismaMock.skillCategory.create.mockRejectedValueOnce(new Error(errorMessage)); + + + const result = await resolver.createCategory(createCategoryInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Failed to create category"); + + expect(prismaMock.skillCategory.create).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.create).toHaveBeenCalledWith({ + data: { + categoryEN: createCategoryInput.categoryEN, + categoryFR: createCategoryInput.categoryFR, + }, + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/createSkill.test.ts b/backend/tests/resolvers/skill/createSkill.test.ts new file mode 100644 index 00000000..1853f948 --- /dev/null +++ b/backend/tests/resolvers/skill/createSkill.test.ts @@ -0,0 +1,198 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { CreateSkillInput } from "../../../src/entities/inputs/skill.input"; +import { SubItemResponse } from "../../../src/entities/response.types"; +import { SkillSubItem } from "../../../src/entities/skillSubItem.entity"; // Vérifie ce chemin d'entité DTO +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - createSkill", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + // Les objets User pour le contexte (ctx.user) ne doivent pas contenir de mot de passe + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const createSkillInput: CreateSkillInput = { + name: "New Skill", + image: "new_skill.png", + categoryId: 1, + }; + + + const mockSkillCategory = { + id: 1, + categoryEN: "Programming", + categoryFR: "Programmation", + }; + + const mockCreatedSkill = { + id: 100, + name: "New Skill", + image: "new_skill.png", + categoryId: 1, + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.skillCategory.findUnique.mockReset(); + prismaMock.skill.create.mockReset(); + + resolver = new SkillResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + + it("should successfully create a new skill for an authenticated admin user", async () => { + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockSkillCategory); + prismaMock.skill.create.mockResolvedValueOnce(mockCreatedSkill); + + const result = await resolver.createSkill(createSkillInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill created successfully"); + expect(result.subItems).toBeDefined(); + expect(result.subItems?.length).toBe(1); + expect(result.subItems?.[0]).toEqual({ + id: mockCreatedSkill.id, + name: mockCreatedSkill.name, + image: mockCreatedSkill.image, + categoryId: mockCreatedSkill.categoryId, + }); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ + where: { id: createSkillInput.categoryId }, + }); + expect(prismaMock.skill.create).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.create).toHaveBeenCalledWith({ + data: { + name: createSkillInput.name, + image: createSkillInput.image, + categoryId: createSkillInput.categoryId, + }, + }); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { + ...baseMockContext, + user: null, + }; + + const result = await resolver.createSkill(createSkillInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.create).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { + ...baseMockContext, + user: mockRegularUser, + }; + + const result = await resolver.createSkill(createSkillInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.create).not.toHaveBeenCalled(); + }); + + it("should return 400 if the category is not found", async () => { + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.createSkill(createSkillInput, adminContext); + + expect(result.code).toBe(400); + expect(result.message).toBe("Category not found"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ + where: { id: createSkillInput.categoryId }, + }); + expect(prismaMock.skill.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category lookup", async () => { + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + const errorMessage = "Database error during category lookup"; + prismaMock.skillCategory.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.createSkill(createSkillInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Failed to create skill"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.create).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill creation", async () => { + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUser, + }; + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockSkillCategory); + const errorMessage = "Database error during skill creation"; + prismaMock.skill.create.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.createSkill(createSkillInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Failed to create skill"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.create).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/deleteCategory.test.ts b/backend/tests/resolvers/skill/deleteCategory.test.ts new file mode 100644 index 00000000..afb475a0 --- /dev/null +++ b/backend/tests/resolvers/skill/deleteCategory.test.ts @@ -0,0 +1,286 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - deleteCategory", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingCategory = { + id: 1, + categoryEN: "Test Category EN", + categoryFR: "Catégorie de Test FR", + }; + + const mockSkills = [ + { id: 10, name: "Skill 1", categoryId: 1, image: "s1.png" }, + { id: 11, name: "Skill 2", categoryId: 1, image: "s2.png" }, + ]; + const mockSkillIds = mockSkills.map(s => s.id); + + beforeEach(() => { + jest.clearAllMocks(); + + prismaMock.skillCategory.findUnique.mockReset(); + prismaMock.skillCategory.delete.mockReset(); + prismaMock.skill.findMany.mockReset(); + prismaMock.skill.deleteMany.mockReset(); + prismaMock.projectSkill.deleteMany.mockReset(); + + resolver = new SkillResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully delete a category with associated skills and project skills by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockResolvedValueOnce(mockSkills); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: mockSkillIds.length }); + prismaMock.skill.deleteMany.mockResolvedValueOnce({ count: mockSkills.length }); + prismaMock.skillCategory.delete.mockResolvedValueOnce(mockExistingCategory); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + + expect(result.code).toBe(200); + expect(result.message).toBe("Category and related skills deleted"); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingCategory.id } }); + + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledWith({ where: { categoryId: mockExistingCategory.id }, select: { id: true } }); + + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledWith({ where: { skillId: { in: mockSkillIds } } }); + + + expect(prismaMock.skill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.deleteMany).toHaveBeenCalledWith({ where: { categoryId: mockExistingCategory.id } }); + + expect(prismaMock.skillCategory.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.delete).toHaveBeenCalledWith({ where: { id: mockExistingCategory.id } }); + }); + + it("should successfully delete a category with no associated skills by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockResolvedValueOnce([]); + prismaMock.skill.deleteMany.mockResolvedValueOnce({ count: 0 }); + prismaMock.skillCategory.delete.mockResolvedValueOnce(mockExistingCategory); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Category and related skills deleted"); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + + expect(prismaMock.skill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.deleteMany).toHaveBeenCalledWith({ where: { categoryId: mockExistingCategory.id } }); + + expect(prismaMock.skillCategory.delete).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated", async () => { + + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + + const result = await resolver.deleteCategory(mockExistingCategory.id, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + + const result = await resolver.deleteCategory(mockExistingCategory.id, regularUserContext); + + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 404 if the category to delete is not found", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.deleteCategory(999, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Category not found"); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category lookup", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during category findUnique"; + prismaMock.skillCategory.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting category"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill lookup", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill findMany"; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting category"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during project skill deletion", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during projectSkill deleteMany"; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockResolvedValueOnce(mockSkills); + prismaMock.projectSkill.deleteMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting category"); + + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill deletion", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill deleteMany"; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockResolvedValueOnce(mockSkills); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: mockSkillIds.length }); + prismaMock.skill.deleteMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting category"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category final deletion", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during category delete"; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skill.findMany.mockResolvedValueOnce(mockSkills); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: mockSkillIds.length }); + prismaMock.skill.deleteMany.mockResolvedValueOnce({ count: mockSkills.length }); + prismaMock.skillCategory.delete.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteCategory(mockExistingCategory.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting category"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.delete).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/deleteSkill.test.ts b/backend/tests/resolvers/skill/deleteSkill.test.ts new file mode 100644 index 00000000..3de1aff1 --- /dev/null +++ b/backend/tests/resolvers/skill/deleteSkill.test.ts @@ -0,0 +1,195 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { SubItemResponse } from "../../../src/entities/response.types"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - deleteSkill", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingSkill = { + id: 10, + name: "Existing Skill", + image: "existing_skill.png", + categoryId: 1, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + prismaMock.skill.findUnique.mockReset(); + prismaMock.skill.delete.mockReset(); + prismaMock.projectSkill.deleteMany.mockReset(); + + resolver = new SkillResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully delete a skill with associated project skills by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 5 }); + prismaMock.skill.delete.mockResolvedValueOnce(mockExistingSkill); + + const result = await resolver.deleteSkill(mockExistingSkill.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill and related sub-items deleted"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingSkill.id } }); + + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledWith({ where: { skillId: mockExistingSkill.id } }); + + expect(prismaMock.skill.delete).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.delete).toHaveBeenCalledWith({ where: { id: mockExistingSkill.id } }); + }); + + it("should successfully delete a skill with no associated project skills by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 0 }); + prismaMock.skill.delete.mockResolvedValueOnce(mockExistingSkill); + + const result = await resolver.deleteSkill(mockExistingSkill.id, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill and related sub-items deleted"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.delete).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result = await resolver.deleteSkill(mockExistingSkill.id, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.delete).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result = await resolver.deleteSkill(mockExistingSkill.id, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.delete).not.toHaveBeenCalled(); + }); + + it("should return 404 if the skill to delete is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.skill.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.deleteSkill(999, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Skill not found"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill lookup", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill findUnique"; + prismaMock.skill.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteSkill(mockExistingSkill.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).not.toHaveBeenCalled(); + expect(prismaMock.skill.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during project skill deletion", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during projectSkill deleteMany"; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.projectSkill.deleteMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteSkill(mockExistingSkill.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.delete).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill deletion", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill delete"; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.projectSkill.deleteMany.mockResolvedValueOnce({ count: 1 }); + prismaMock.skill.delete.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.deleteSkill(mockExistingSkill.id, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error deleting skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.projectSkill.deleteMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.delete).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/skillList.test.ts b/backend/tests/resolvers/skill/skillList.test.ts new file mode 100644 index 00000000..32123d98 --- /dev/null +++ b/backend/tests/resolvers/skill/skillList.test.ts @@ -0,0 +1,119 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { CategoryResponse } from "../../../src/entities/response.types"; +import { SkillCategory as PrismaSkillCategory, Skill as PrismaSkill } from "@prisma/client"; + +describe("SkillResolver - skillList", () => { + let resolver: SkillResolver; + + const mockCategoriesWithSkills: (PrismaSkillCategory & { skills: PrismaSkill[] })[] = [ + { + id: 1, + categoryEN: "Programming", + categoryFR: "Programmation", + skills: [ + { id: 101, name: "JavaScript", image: "js.png", categoryId: 1 }, + { id: 102, name: "TypeScript", image: "ts.png", categoryId: 1 }, + ], + }, + { + id: 2, + categoryEN: "Design", + categoryFR: "Conception", + skills: [], + }, + { + id: 3, + categoryEN: "DevOps", + categoryFR: "DevOps", + skills: [{ id: 103, name: "Docker", image: "docker.png", categoryId: 3 }], + }, + ]; + + const expectedMappedCategories = [ + { + id: 1, + categoryEN: "Programming", + categoryFR: "Programmation", + skills: [ + { id: 101, name: "JavaScript", image: "js.png", categoryId: 1 }, + { id: 102, name: "TypeScript", image: "ts.png", categoryId: 1 }, + ], + }, + { + id: 2, + categoryEN: "Design", + categoryFR: "Conception", + skills: [], + }, + { + id: 3, + categoryEN: "DevOps", + categoryFR: "DevOps", + skills: [{ id: 103, name: "Docker", image: "docker.png", categoryId: 3 }], + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.skillCategory.findMany.mockReset(); + resolver = new SkillResolver(prismaMock); + }); + + it("should return all skill categories with their associated skills successfully", async () => { + + prismaMock.skillCategory.findMany.mockResolvedValueOnce(mockCategoriesWithSkills); + + + const result: CategoryResponse = await resolver.skillList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Categories fetched successfully"); + expect(result.categories).toBeDefined(); + expect(result.categories).toEqual(expectedMappedCategories); + + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledWith({ + include: { skills: true }, + orderBy: { id: "asc" }, + }); + }); + + it("should return an empty array if no skill categories are found", async () => { + + prismaMock.skillCategory.findMany.mockResolvedValueOnce([]); + + + const result: CategoryResponse = await resolver.skillList(); + + expect(result.code).toBe(200); + expect(result.message).toBe("Categories fetched successfully"); + expect(result.categories).toBeDefined(); + expect(result.categories).toEqual([]); + + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledWith({ + include: { skills: true }, + orderBy: { id: "asc" }, + }); + }); + + it("should return a 500 error if fetching categories fails", async () => { + + const errorMessage = "Database connection error"; + prismaMock.skillCategory.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result: CategoryResponse = await resolver.skillList(); + + expect(result.code).toBe(500); + expect(result.message).toBe("Failed to fetch categories"); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findMany).toHaveBeenCalledWith({ + include: { skills: true }, + orderBy: { id: "asc" }, + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/updateCategory.test.ts b/backend/tests/resolvers/skill/updateCategory.test.ts new file mode 100644 index 00000000..76a56c87 --- /dev/null +++ b/backend/tests/resolvers/skill/updateCategory.test.ts @@ -0,0 +1,257 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { UpdateCategoryInput } from "../../../src/entities/inputs/skill.input"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - updateCategory", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockEditorUser: User = { + id: 3, + firstname: "Editor", + lastname: "User", + email: "editor@example.com", + role: UserRole.editor, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingCategory = { + id: 1, + categoryEN: "Old English Name", + categoryFR: "Ancien Nom Français", + }; + + const fullUpdateInput: UpdateCategoryInput = { + categoryEN: "New English Name", + categoryFR: "Nouveau Nom Français", + }; + + + const partialUpdateInput: UpdateCategoryInput = { + categoryEN: "Updated English Name", + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.skillCategory.findUnique.mockReset(); + prismaMock.skillCategory.update.mockReset(); + + resolver = new SkillResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + + it("should successfully update a category with full data by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skillCategory.update.mockResolvedValueOnce({ + ...mockExistingCategory, + ...fullUpdateInput, + }); + + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Category updated"); + expect(result.categories).toBeDefined(); + expect(result.categories?.length).toBe(1); + expect(result.categories?.[0]).toEqual({ + id: mockExistingCategory.id, + categoryEN: fullUpdateInput.categoryEN, + categoryFR: fullUpdateInput.categoryFR, + skills: [], + }); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ where: { id: mockExistingCategory.id } }); + expect(prismaMock.skillCategory.update).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).toHaveBeenCalledWith({ + where: { id: mockExistingCategory.id }, + data: fullUpdateInput, + }); + }); + + it("should successfully update a category with full data by an editor user", async () => { + const editorContext: MyContext = { ...baseMockContext, user: mockEditorUser }; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skillCategory.update.mockResolvedValueOnce({ + ...mockExistingCategory, + ...fullUpdateInput, + }); + + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, editorContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Category updated"); + expect(result.categories).toBeDefined(); + expect(result.categories?.length).toBe(1); + expect(result.categories?.[0]).toEqual({ + id: mockExistingCategory.id, + categoryEN: fullUpdateInput.categoryEN, + categoryFR: fullUpdateInput.categoryFR, + skills: [], + }); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).toHaveBeenCalledTimes(1); + }); + + it("should successfully update a category with partial data by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + prismaMock.skillCategory.update.mockResolvedValueOnce({ + ...mockExistingCategory, + categoryEN: partialUpdateInput.categoryEN!, + categoryFR: mockExistingCategory.categoryFR, + }); + + + const result = await resolver.updateCategory(mockExistingCategory.id, partialUpdateInput, adminContext); + + + expect(result.code).toBe(200); + expect(result.message).toBe("Category updated"); + expect(result.categories).toBeDefined(); + expect(result.categories?.[0].categoryEN).toBe(partialUpdateInput.categoryEN); + + expect(result.categories?.[0].categoryFR).toBe(mockExistingCategory.categoryFR); + + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).toHaveBeenCalledWith({ + where: { id: mockExistingCategory.id }, + data: { + categoryEN: partialUpdateInput.categoryEN, + categoryFR: mockExistingCategory.categoryFR, + }, + }); + }); + + it("should return 401 if no user is authenticated", async () => { + + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.categories).toBeUndefined(); + + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.update).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin or editor", async () => { + + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin or Editor role required."); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.update).not.toHaveBeenCalled(); + }); + + it("should return 404 if the category to update is not found", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(null); + + + const result = await resolver.updateCategory(999, fullUpdateInput, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Category not found"); + expect(result.categories).toBeUndefined(); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ where: { id: 999 } }); + expect(prismaMock.skillCategory.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category lookup", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + const errorMessage = "Database error during findUnique"; + prismaMock.skillCategory.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating category"); + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category update", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockExistingCategory); + + const errorMessage = "Database error during update"; + prismaMock.skillCategory.update.mockRejectedValueOnce(new Error(errorMessage)); + + + const result = await resolver.updateCategory(mockExistingCategory.id, fullUpdateInput, adminContext); + + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating category"); + + + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/skill/updateSkill.test.ts b/backend/tests/resolvers/skill/updateSkill.test.ts new file mode 100644 index 00000000..54b4b5f7 --- /dev/null +++ b/backend/tests/resolvers/skill/updateSkill.test.ts @@ -0,0 +1,357 @@ +import "reflect-metadata"; +import { SkillResolver } from "../../../src/resolvers/skill.resolver"; +import { prismaMock } from "../../singleton"; +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; +import { UpdateSkillInput } from "../../../src/entities/inputs/skill.input"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("SkillResolver - updateSkill", () => { + let resolver: SkillResolver; + + const mockCookies = mockDeep(); + + const mockAdminUser: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockEditorUser: User = { + id: 3, + firstname: "Editor", + lastname: "User", + email: "editor@example.com", + role: UserRole.editor, + isPasswordChange: true, + }; + + const mockRegularUser: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "regular@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + const mockExistingSkill = { + id: 10, + name: "Old Skill Name", + image: "old_skill_image.png", + categoryId: 1, + }; + + const mockNewValidCategory = { + id: 2, + categoryEN: "Design", + categoryFR: "Conception", + }; + + const fullUpdateInput: UpdateSkillInput = { + name: "New Skill Name", + image: "new_skill_image.png", + categoryId: 2, + }; + + const partialUpdateNameInput: UpdateSkillInput = { + name: "Updated Skill Name Only", + }; + + const partialUpdateImageInput: UpdateSkillInput = { + image: "updated_image_only.png", + }; + + const partialUpdateCategoryInput: UpdateSkillInput = { + categoryId: 2, + }; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.skill.findUnique.mockReset(); + prismaMock.skill.update.mockReset(); + prismaMock.skillCategory.findUnique.mockReset(); + + resolver = new SkillResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully update a skill with full data by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockNewValidCategory); // For data.categoryId validation + prismaMock.skill.update.mockResolvedValueOnce({ + ...mockExistingSkill, + ...fullUpdateInput, + }); + + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill updated"); + expect(result.subItems).toBeDefined(); + expect(result.subItems?.length).toBe(1); + expect(result.subItems?.[0]).toEqual({ + id: mockExistingSkill.id, + name: fullUpdateInput.name, + image: fullUpdateInput.image, + categoryId: fullUpdateInput.categoryId, + }); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ where: { id: fullUpdateInput.categoryId } }); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).toHaveBeenCalledWith({ + where: { id: mockExistingSkill.id }, + data: { + name: fullUpdateInput.name, + image: fullUpdateInput.image, + categoryId: fullUpdateInput.categoryId, + }, + }); + }); + + it("should successfully update a skill with full data by an editor user", async () => { + + const editorContext: MyContext = { ...baseMockContext, user: mockEditorUser }; + + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockNewValidCategory); + prismaMock.skill.update.mockResolvedValueOnce({ + ...mockExistingSkill, + ...fullUpdateInput, + }); + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, editorContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill updated"); + expect(result.subItems).toBeDefined(); + expect(result.subItems?.length).toBe(1); + expect(result.subItems?.[0]).toEqual({ + id: mockExistingSkill.id, + name: fullUpdateInput.name, + image: fullUpdateInput.image, + categoryId: fullUpdateInput.categoryId, + }); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + }); + + it("should successfully update skill name partially by an admin user", async () => { + + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skill.update.mockResolvedValueOnce({ + ...mockExistingSkill, + name: partialUpdateNameInput.name ?? "", + }); + + const result = await resolver.updateSkill(mockExistingSkill.id, partialUpdateNameInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill updated"); + expect(result.subItems).toBeDefined(); + expect(result.subItems?.[0].name).toBe(partialUpdateNameInput.name); + expect(result.subItems?.[0].image).toBe(mockExistingSkill.image); + expect(result.subItems?.[0].categoryId).toBe(mockExistingSkill.categoryId); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).toHaveBeenCalledWith({ + where: { id: mockExistingSkill.id }, + data: { + name: partialUpdateNameInput.name, + image: mockExistingSkill.image, + categoryId: mockExistingSkill.categoryId, + }, + }); + }); + + it("should successfully update skill image partially by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skill.update.mockResolvedValueOnce({ + ...mockExistingSkill, + image: partialUpdateImageInput.image ?? "", + }); + + const result = await resolver.updateSkill(mockExistingSkill.id, partialUpdateImageInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill updated"); + expect(result.subItems?.[0].image).toBe(partialUpdateImageInput.image); + expect(result.subItems?.[0].name).toBe(mockExistingSkill.name); + expect(result.subItems?.[0].categoryId).toBe(mockExistingSkill.categoryId); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + }); + + it("should successfully update skill category partially by an admin user", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockNewValidCategory); + prismaMock.skill.update.mockResolvedValueOnce({ + ...mockExistingSkill, + categoryId: partialUpdateCategoryInput.categoryId ?? 1, + }); + + const result = await resolver.updateSkill(mockExistingSkill.id, partialUpdateCategoryInput, adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Skill updated"); + expect(result.subItems?.[0].categoryId).toBe(partialUpdateCategoryInput.categoryId); + expect(result.subItems?.[0].name).toBe(mockExistingSkill.name); + expect(result.subItems?.[0].image).toBe(mockExistingSkill.image); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + }); + + + it("should return 401 if no user is authenticated", async () => { + const unauthenticatedContext: MyContext = { ...baseMockContext, user: null }; + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin or editor", async () => { + const regularUserContext: MyContext = { ...baseMockContext, user: mockRegularUser }; + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin or Editor role required."); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).not.toHaveBeenCalled(); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + }); + + it("should return 404 if the skill to update is not found", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + prismaMock.skill.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.updateSkill(999, fullUpdateInput, adminContext); + + expect(result.code).toBe(404); + expect(result.message).toBe("Skill not found"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + }); + + it("should return 400 if categoryId is provided but invalid", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const invalidCategoryInput: UpdateSkillInput = { + name: "Test Skill", + categoryId: 999, // Invalid category ID + }; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.updateSkill(mockExistingSkill.id, invalidCategoryInput, adminContext); + + expect(result.code).toBe(400); + expect(result.message).toBe("Invalid category"); + expect(result.subItems).toBeUndefined(); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledWith({ where: { id: invalidCategoryInput.categoryId } }); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during initial skill lookup", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill findUnique"; + prismaMock.skill.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + expect(prismaMock.skillCategory.findUnique).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during category validation lookup", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during category findUnique"; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during skill update", async () => { + const adminContext: MyContext = { ...baseMockContext, user: mockAdminUser }; + const errorMessage = "Database error during skill update"; + + prismaMock.skill.findUnique.mockResolvedValueOnce(mockExistingSkill); + prismaMock.skillCategory.findUnique.mockResolvedValueOnce(mockNewValidCategory); + prismaMock.skill.update.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.updateSkill(mockExistingSkill.id, fullUpdateInput, adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error updating skill"); + + expect(prismaMock.skill.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skillCategory.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.skill.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/user/changePassword.test.ts b/backend/tests/resolvers/user/changePassword.test.ts new file mode 100644 index 00000000..f5cffde3 --- /dev/null +++ b/backend/tests/resolvers/user/changePassword.test.ts @@ -0,0 +1,195 @@ +import "reflect-metadata"; +import { UserResolver } from "../../../src/resolvers/user.resolver"; +import { prismaMock } from "../../singleton"; +import * as argon2 from 'argon2'; +import * as regexUtils from "../../../src/regex"; +import { UserRole } from "../../../src/entities/user.entity"; + +// Mocks les dépendances externes +jest.mock("argon2"); +jest.mock("../../../src/regex", () => ({ + passwordRegex: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])(?=.{9,})/, + checkRegex: jest.fn(), // Mock checkRegex +})); + +describe("UserResolver - changePassword", () => { + let resolver: UserResolver; + + // Un utilisateur existant mocké dans la base de données + const mockExistingUser = { + id: 1, + firstname: "John", + lastname: "Doe", + email: "john.doe@example.com", + password: "old_hashed_password", + role: UserRole.admin, + isPasswordChange: false, + }; + + const newValidPassword = "NewValidPassword123!"; + const newInvalidPassword = "short"; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.user.findUnique.mockReset(); + prismaMock.user.update.mockReset(); + + resolver = new UserResolver(prismaMock); + + // Mocks par défaut pour les dépendances externes + (regexUtils.checkRegex as jest.Mock).mockReturnValue(true); + (argon2.hash as jest.Mock).mockResolvedValue("new_hashed_password"); + }); + + // --- Scénarios de Test --- + + it("should successfully change the user's password", async () => { + + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + + prismaMock.user.update.mockResolvedValueOnce({ + ...mockExistingUser, + password: "new_hashed_password", + isPasswordChange: true, + } as any); + + const result = await resolver.changePassword( + mockExistingUser.email, + newValidPassword + ); + + + expect(result.code).toBe(200); + expect(result.message).toBe("Password updated successfully."); + + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(regexUtils.checkRegex).toHaveBeenCalledWith(regexUtils.passwordRegex, newValidPassword); + + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { email: mockExistingUser.email }, + }); + + expect(argon2.hash).toHaveBeenCalledTimes(1); + expect(argon2.hash).toHaveBeenCalledWith(newValidPassword); + + expect(prismaMock.user.update).toHaveBeenCalledTimes(1); + expect(prismaMock.user.update).toHaveBeenCalledWith({ + where: { email: mockExistingUser.email }, + data: { + password: "new_hashed_password", + isPasswordChange: true, + }, + }); + }); + + it("should return 400 if the new password format is invalid", async () => { + + (regexUtils.checkRegex as jest.Mock).mockReturnValue(false); + + const result = await resolver.changePassword( + mockExistingUser.email, + newInvalidPassword + ); + + expect(result.code).toBe(400); + expect(result.message).toBe( + "The password must contain at least 9 characters, with at least one uppercase letter, one lowercase letter, one number and one symbol." + ); + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(regexUtils.checkRegex).toHaveBeenCalledWith(regexUtils.passwordRegex, newInvalidPassword); + + expect(prismaMock.user.findUnique).not.toHaveBeenCalled(); + expect(argon2.hash).not.toHaveBeenCalled(); + expect(prismaMock.user.update).not.toHaveBeenCalled(); + }); + + it("should return 404 if the user is not found", async () => { + + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + + const result = await resolver.changePassword( + "nonexistent@example.com", + newValidPassword + ); + + expect(result.code).toBe(404); + expect(result.message).toBe("User not found with this email."); + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { email: "nonexistent@example.com" }, + }); + + expect(argon2.hash).not.toHaveBeenCalled(); + expect(prismaMock.user.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during user lookup", async () => { + + const errorMessage = "Database connection error during findUnique"; + prismaMock.user.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + + const result = await resolver.changePassword( + mockExistingUser.email, + newValidPassword + ); + + expect(result.code).toBe(500); + expect(result.message).toBe("Server error while updating password."); + + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.hash).not.toHaveBeenCalled(); + expect(prismaMock.user.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during password hashing", async () => { + + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + + const errorMessage = "Argon2 hashing failed"; + (argon2.hash as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.changePassword( + mockExistingUser.email, + newValidPassword + ); + + expect(result.code).toBe(500); + expect(result.message).toBe("Server error while updating password."); + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.hash).toHaveBeenCalledTimes(1); + expect(prismaMock.user.update).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during database update", async () => { + + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + + const errorMessage = "Database connection error during update"; + prismaMock.user.update.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.changePassword( + mockExistingUser.email, + newValidPassword + ); + + + expect(result.code).toBe(500); + expect(result.message).toBe("Server error while updating password."); + + expect(regexUtils.checkRegex).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.hash).toHaveBeenCalledTimes(1); + expect(prismaMock.user.update).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/user/login.test.ts b/backend/tests/resolvers/user/login.test.ts new file mode 100644 index 00000000..1244d53b --- /dev/null +++ b/backend/tests/resolvers/user/login.test.ts @@ -0,0 +1,172 @@ +import "reflect-metadata"; +import { UserResolver } from "../../../src/resolvers/user.resolver"; +import { prismaMock } from "../../singleton"; +import * as argon2 from 'argon2'; +import * as jwt from 'jsonwebtoken'; +import { UserRole } from "../../../src/entities/user.entity"; +import { MyContext } from "../../../src"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +jest.mock("argon2"); +jest.mock("jsonwebtoken"); + +describe("UserResolver - login", () => { + let resolver: UserResolver; + + // Créons un mock profond pour la classe Cookies. + const mockCookies = mockDeep(); + + // Mock pour MyContext, incluant toutes les propriétés requises par MyContext + const mockContext: MyContext = { + req: {} as any, // Mock un objet req vide ou minimal si non utilisé + res: {} as any, // Mock un objet res vide ou minimal si non utilisé + cookies: mockCookies, + user: null, + apiKey: undefined, + + }; + + const mockExistingUser = { + id: 1, + firstname: "Test", + lastname: "User", + email: "test@example.com", + password: "hashed_password_from_db", + role: UserRole.admin, + isPasswordChange: false, + pseudo: null, + ban: false, + }; + + const loginInput = { + email: mockExistingUser.email, + password: "plain_password", + }; + + const originalJwtSecret = process.env.JWT_SECRET; + + beforeEach(() => { + jest.clearAllMocks(); // Efface l'historique de tous les mocks Jest + prismaMock.user.findUnique.mockReset(); + + resolver = new UserResolver(prismaMock); + + // Mocks par défaut pour les dépendances externes + (argon2.verify as jest.Mock).mockResolvedValue(true); + (jwt.sign as jest.Mock).mockReturnValue("fake-jwt-token"); + + // mockCookies est un mock profond, donc 'mockClear()' fonctionne sans problème de type. + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + + + process.env.JWT_SECRET = "your_test_secret"; + }); + + afterEach(() => { + + process.env.JWT_SECRET = originalJwtSecret; + }); + + // --- Scénarios de Test --- + + it("should successfully log in a user and set a cookie", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + + const result = await resolver.login(loginInput, mockContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Login successful."); + expect(result.token).toBe("fake-jwt-token"); + + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { email: loginInput.email }, + }); + + expect(argon2.verify).toHaveBeenCalledTimes(1); + expect(argon2.verify).toHaveBeenCalledWith( + mockExistingUser.password, + loginInput.password + ); + + expect(jwt.sign).toHaveBeenCalledTimes(1); + expect(jwt.sign).toHaveBeenCalledWith( + { userId: mockExistingUser.id }, + "your_test_secret", + { expiresIn: "7d" } + ); + + expect(mockContext.cookies.set).toHaveBeenCalledTimes(1); + expect(mockContext.cookies.set).toHaveBeenCalledWith( + "jwt", + "fake-jwt-token", + expect.objectContaining({ + httpOnly: true, + secure: expect.any(Boolean), + sameSite: 'lax', + maxAge: 1000 * 60 * 60 * 24 * 7, + path: '/', + }) + ); + }); + + it("should return 401 if user is not found", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const result = await resolver.login(loginInput, mockContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Invalid credentials (email or password incorrect)."); + expect(result.token).toBeUndefined(); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.verify).not.toHaveBeenCalled(); + expect(jwt.sign).not.toHaveBeenCalled(); + expect(mockContext.cookies.set).not.toHaveBeenCalled(); + }); + + it("should return 401 if password is incorrect", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + (argon2.verify as jest.Mock).mockResolvedValueOnce(false); + + const result = await resolver.login(loginInput, mockContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Invalid credentials (email or password incorrect)."); + expect(result.token).toBeUndefined(); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.verify).toHaveBeenCalledTimes(1); + expect(jwt.sign).not.toHaveBeenCalled(); + expect(mockContext.cookies.set).not.toHaveBeenCalled(); + }); + + it("should return 500 if JWT_SECRET is not set", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(mockExistingUser); + delete process.env.JWT_SECRET; // Supprime le secret JWT pour ce test + + const result = await resolver.login(loginInput, mockContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Please check your JWT configuration !"); + expect(result.token).toBeUndefined(); + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1); + expect(argon2.verify).toHaveBeenCalledTimes(1); + expect(jwt.sign).not.toHaveBeenCalled(); + expect(mockContext.cookies.set).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error", async () => { + const errorMessage = "Database connection failed"; + prismaMock.user.findUnique.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.login(loginInput, mockContext); + + expect(result.code).toBe(500); + expect(result.message).toBe(errorMessage); + expect(result.token).toBeUndefined(); + expect(argon2.verify).not.toHaveBeenCalled(); + expect(jwt.sign).not.toHaveBeenCalled(); + expect(mockContext.cookies.set).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/user/logout.test.ts b/backend/tests/resolvers/user/logout.test.ts new file mode 100644 index 00000000..a711c8f1 --- /dev/null +++ b/backend/tests/resolvers/user/logout.test.ts @@ -0,0 +1,108 @@ +import "reflect-metadata"; +import { UserResolver } from "../../../src/resolvers/user.resolver"; +import { prismaMock } from "../../singleton"; // Keep for consistency, though not used by logout +import { MyContext } from "../../../src"; +import { User, UserRole } from "../../../src/entities/user.entity"; // Import User and UserRole +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("UserResolver - logout", () => { + let resolver: UserResolver; + + const mockCookies = mockDeep(); + + const mockAuthenticatedUser: User = { + id: 1, + firstname: "Logged", + lastname: "In", + email: "logged.in@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + resolver = new UserResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + it("should successfully log out an authenticated user", async () => { + + const authenticatedContext: MyContext = { + ...baseMockContext, + user: mockAuthenticatedUser, + }; + + const result = await resolver.logout(authenticatedContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Logged out successfully."); + + expect(mockCookies.set).toHaveBeenCalledTimes(1); + expect(mockCookies.set).toHaveBeenCalledWith( + "jwt", + "", + expect.objectContaining({ + httpOnly: true, + secure: expect.any(Boolean), + sameSite: 'lax', + expires: expect.any(Date), + path: '/', + }) + ); + + const setArgs = mockCookies.set.mock.calls[0]; + expect(setArgs[2]?.expires?.getTime()).toBe(0); + + expect(authenticatedContext.user).toBeNull(); + }); + + it("should return 401 if no user is authenticated (ctx.user is null)", async () => { + + const unauthenticatedContext: MyContext = { + ...baseMockContext, + user: null, + }; + + const result = await resolver.logout(unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + + expect(mockCookies.set).not.toHaveBeenCalled(); + + expect(unauthenticatedContext.user).toBeNull(); + }); + + it("should return 500 for an unexpected server error during cookie handling", async () => { + + const authenticatedContext: MyContext = { + ...baseMockContext, + user: mockAuthenticatedUser, + }; + + + const errorMessage = "Simulated cookie setting error"; + mockCookies.set.mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + + const result = await resolver.logout(authenticatedContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("An error occurred during logout."); + + expect(mockCookies.set).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/user/register.test.ts b/backend/tests/resolvers/user/register.test.ts new file mode 100644 index 00000000..d4ce13e8 --- /dev/null +++ b/backend/tests/resolvers/user/register.test.ts @@ -0,0 +1,120 @@ +import "reflect-metadata"; +import { UserResolver } from "../../../src/resolvers/user.resolver"; +import { CreateUserInput } from "../../../src/entities/inputs/user.input"; +import { UserRole } from "../../../src/entities/user.entity"; +import { prismaMock } from "../../singleton"; +import * as mailService from "../../../src/mail/mail.service"; +import * as passwordUtils from "../../../src/lib/generateSecurePassword"; +import { emailRegex, checkRegex } from "../../../src/regex"; +import * as argon2 from 'argon2'; + +// Mocks les dépendances externes +jest.mock("../../../src/mail/mail.service"); +jest.mock("../../../src/lib/generateSecurePassword"); +jest.mock("../../../src/regex", () => ({ + checkRegex: jest.fn(), + emailRegex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +})); +jest.mock("argon2"); + +describe("UserResolver - registerUser", () => { + let resolver: UserResolver; + + beforeEach(() => { + jest.clearAllMocks(); + + + prismaMock.user.findUnique.mockReset(); + prismaMock.user.create.mockReset(); + + resolver = new UserResolver(prismaMock); + + // Définit les mocks par défaut pour les autres fonctions utilisées dans les tests + (passwordUtils.generateSecurePassword as jest.Mock).mockReturnValue("Secure123!"); + (argon2.hash as jest.Mock).mockResolvedValue("hashed-password"); + (mailService.sendEmail as jest.Mock).mockResolvedValue(undefined); + (checkRegex as jest.Mock).mockReturnValue(true); + }); + + it("should return error if email already exists", async () => { + const input: CreateUserInput = { + firstname: "Alex", + lastname: "Renard", + email: "alex@example.com", + role: UserRole.admin, + }; + + // Pour ce test, nous voulons que findUnique retourne un utilisateur existant + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 1, + firstname: "Alex", + lastname: "Renard", + email: "alex@example.com", + role: UserRole.admin, + isPasswordChange: false, + password: "some-hashed-password", + pseudo: null, + ban: false + } as any); + + const result = await resolver.registerUser(input); + + expect(result.code).toBe(409); + expect(result.message).toBe("Email already exists"); + }); + + it("should create a new user and send email", async () => { + const input2: CreateUserInput = { + firstname: "Jean", + lastname: "Dupont", + email: "jean.dupont@example.com", + role: UserRole.admin, + }; + + console.log("🧪 MOCKING findUnique → null"); + + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + // Mock pour la création d'utilisateur + prismaMock.user.create.mockResolvedValueOnce({ + id: 1, + firstname: input2.firstname, + lastname: input2.lastname, + email: input2.email, + role: input2.role, + isPasswordChange: false, + password: "hashed-password", + pseudo: null, + ban: false, + } as any); + + const result = await resolver.registerUser(input2); + console.log("🧪 RESULT =", result.message); + console.log("🧪 RESULT =", result); + + expect(result.code).toBe(201); + expect(result.message).toBe("User registered and email sent"); + expect(result.user?.email).toBe(input2.email); + expect(mailService.sendEmail).toHaveBeenCalledTimes(1); + expect(mailService.sendEmail).toHaveBeenCalledWith( + input2.email, + "Votre compte a été créé", + expect.any(String), + expect.any(String) + ); + expect(passwordUtils.generateSecurePassword).toHaveBeenCalledTimes(1); + expect(argon2.hash).toHaveBeenCalledWith("Secure123!"); + + expect(prismaMock.user.create).toHaveBeenCalledTimes(1); + expect(prismaMock.user.create).toHaveBeenCalledWith({ + data: { + firstname: input2.firstname, + lastname: input2.lastname, + email: input2.email, + password: "hashed-password", + role: input2.role, + isPasswordChange: false, + }, + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/resolvers/user/userList.test.ts b/backend/tests/resolvers/user/userList.test.ts new file mode 100644 index 00000000..53cca8fe --- /dev/null +++ b/backend/tests/resolvers/user/userList.test.ts @@ -0,0 +1,147 @@ +import "reflect-metadata"; +import { UserResolver } from "../../../src/resolvers/user.resolver"; +import { prismaMock } from "../../singleton"; +import { User, UserRole } from "../../../src/entities/user.entity"; // Assure-toi que User et UserRole sont bien importés +import { MyContext } from "../../../src"; +import Cookies from 'cookies'; +import { mockDeep } from 'jest-mock-extended'; + +describe("UserResolver - userList", () => { + let resolver: UserResolver; + + const mockCookies = mockDeep(); + + + const mockAdminUserInContext: User = { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + role: UserRole.admin, + isPasswordChange: true, + }; + + const mockRegularUserInContext: User = { + id: 2, + firstname: "Regular", + lastname: "User", + email: "user@example.com", + role: UserRole.view, + isPasswordChange: true, + }; + + // Contexte mock de base. Nous modifierons la propriété 'user' pour chaque cas de test. + const baseMockContext: MyContext = { + req: {} as any, + res: {} as any, + cookies: mockCookies, + user: null, + apiKey: undefined, + }; + + // Les utilisateurs mockés retournés par Prisma's findMany. + const mockUsersFromDb = [ + { + id: 1, + firstname: "Admin", + lastname: "User", + email: "admin@example.com", + password: "hashed_password_admin", + role: UserRole.admin, + isPasswordChange: true, + }, + { + id: 2, + firstname: "Regular", + lastname: "User", + email: "user@example.com", + password: "hashed_password_user", + role: UserRole.admin, + isPasswordChange: true, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.user.findMany.mockReset(); + + resolver = new UserResolver(prismaMock); + + mockCookies.set.mockClear(); + mockCookies.get.mockClear(); + }); + + // --- Scénarios de Test --- + + it("should return a list of users for an authenticated admin user", async () => { + // Définis le contexte pour un utilisateur admin en utilisant le mockAdminUserInContext + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUserInContext, // Utilise le mock sans 'password' + }; + + // Mock le findMany de Prisma pour retourner une liste d'utilisateurs + prismaMock.user.findMany.mockResolvedValueOnce(mockUsersFromDb as any); // Caste si nécessaire pour s'assurer que Prisma le consomme bien + + const result = await resolver.userList(adminContext); + + expect(result.code).toBe(200); + expect(result.message).toBe("Users fetched"); + expect(result.users).toBeDefined(); + expect(result.users?.length).toBe(2); + expect(result.users?.[0].email).toBe("admin@example.com"); + expect(result.users?.[1].email).toBe("user@example.com"); + + expect(prismaMock.user.findMany).toHaveBeenCalledTimes(1); + }); + + it("should return 401 if no user is authenticated (ctx.user is null)", async () => { + const unauthenticatedContext: MyContext = { + ...baseMockContext, + user: null, // Correctement défini à null + }; + + const result = await resolver.userList(unauthenticatedContext); + + expect(result.code).toBe(401); + expect(result.message).toBe("Authentication required."); + expect(result.users).toBeUndefined(); + + expect(prismaMock.user.findMany).not.toHaveBeenCalled(); + }); + + it("should return 403 if authenticated user is not an admin", async () => { + // Définis le contexte pour un utilisateur standard en utilisant le mockRegularUserInContext + const regularUserContext: MyContext = { + ...baseMockContext, + user: mockRegularUserInContext, // Utilise le mock sans 'password' + }; + + const result = await resolver.userList(regularUserContext); + + expect(result.code).toBe(403); + expect(result.message).toBe("Access denied. Admin role required."); + expect(result.users).toBeUndefined(); + + expect(prismaMock.user.findMany).not.toHaveBeenCalled(); + }); + + it("should return 500 for an unexpected server error during user fetching", async () => { + // Définis le contexte pour un utilisateur admin + const adminContext: MyContext = { + ...baseMockContext, + user: mockAdminUserInContext, // Utilise le mock sans 'password' + }; + + const errorMessage = "Database connection error during findMany"; + prismaMock.user.findMany.mockRejectedValueOnce(new Error(errorMessage)); + + const result = await resolver.userList(adminContext); + + expect(result.code).toBe(500); + expect(result.message).toBe("Error fetching users"); + expect(result.users).toBeUndefined(); + + expect(prismaMock.user.findMany).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/backend/tests/routes/badge-project.test-not.ts b/backend/tests/routes/badge-project.test-not.ts new file mode 100644 index 00000000..53165af5 --- /dev/null +++ b/backend/tests/routes/badge-project.test-not.ts @@ -0,0 +1,169 @@ +import express from 'express'; +import { PrismaClient } from '@prisma/client'; +import { Request, Response } from 'express'; + + +const mockGenerateBadgeSvg = jest.fn((label, message, color, labelColor, icon, iconColor, iconSide) => { + const iconPart = icon ? `icon-${icon.base64.slice(0, 10)}` : 'no-icon'; + return `${label}-${message}-${color}-${labelColor}-${iconPart}-${iconColor}-${iconSide}`; +}); +jest.mock('../../src/lib/badgeGenerator', () => ({ + generateBadgeSvg: mockGenerateBadgeSvg, +})); + + +const mockLoadedLogos = new Map(); +jest.mock('../../src/lib/logoLoader', () => ({ + loadedLogos: mockLoadedLogos, + loadLogos: jest.fn(), +})); + +// Mock PrismaClient globally to control its behavior +const mockPrismaClient = jest.fn(() => ({ + project: { + count: jest.fn(), + }, +})); +jest.mock('@prisma/client', () => ({ + PrismaClient: mockPrismaClient, +})); + +const app = express(); + +const prisma = new (mockPrismaClient as any)(); + +app.get('/badge/stats/projects-count', async (req: Request, res: Response) => { + try { + const projectCount = await prisma.project.count(); + const logoData = mockLoadedLogos.get('github'); + + + if (!logoData) console.warn("Logo 'github' non trouvé pour le badge projets."); + + const svg = mockGenerateBadgeSvg( + 'Projets', + String(projectCount), + '4CAF50', + '2F4F4F', + logoData, + 'white', + 'right' + ); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.send(svg); + } catch (error) { + console.error("Erreur lors de la génération du badge des projets:", error); + res.setHeader('Content-Type', 'image/svg+xml'); + res.status(500).send('Error'); + } +}); + +describe('GET /badge/stats/projects-count', () => { + let consoleWarnSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + + jest.clearAllMocks(); + + (prisma.project.count as jest.Mock).mockResolvedValue(7); + + mockLoadedLogos.clear(); + mockLoadedLogos.set('github', { base64: 'fakebase64github', mimeType: 'image/svg+xml' }); + + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('renvoie le SVG du badge avec le nombre de projets mocké', async () => { + const expectedProjectCount = 7; + (prisma.project.count as jest.Mock).mockResolvedValueOnce(expectedProjectCount); + + const expectedSvg = `Projets-${expectedProjectCount}-4CAF50-2F4F4F-icon-fakebase64-white-right`; + + const response = await request(app).get('/badge/stats/projects-count'); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toMatch(/image\/svg\+xml/); + expect(response.text).toBe(expectedSvg); + + expect(prisma.project.count).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).toHaveBeenCalledWith( + 'Projets', + String(expectedProjectCount), + '4CAF50', + '2F4F4F', + { base64: 'fakebase64github', mimeType: 'image/svg+xml' }, + 'white', + 'right' + ); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('devrait renvoyer un badge d\'erreur 500 si Prisma count échoue', async () => { + (prisma.project.count as jest.Mock).mockRejectedValueOnce(new Error("DB Error")); + + const response = await request(app).get('/badge/stats/projects-count'); + + expect(response.statusCode).toBe(500); + expect(response.headers['content-type']).toMatch(/image\/svg\+xml/); + expect(response.text).toContain('Error'); + + + expect(prisma.project.count).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('devrait renvoyer un badge d\'erreur 500 si generateBadgeSvg lève une erreur', async () => { + mockGenerateBadgeSvg.mockImplementationOnce(() => { + throw new Error("SVG generation failed"); + }); + + const response = await request(app).get('/badge/stats/projects-count'); + + expect(response.statusCode).toBe(500); + expect(response.headers['content-type']).toMatch(/image\/svg\+xml/); + expect(response.text).toContain('Error'); + + // Verify mocks were called + expect(prisma.project.count).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + + it('devrait renvoyer un badge réussi même si le logo github n\'est pas trouvé', async () => { + mockLoadedLogos.clear(); + + const expectedSvg = `Projets-7-4CAF50-2F4F4F-no-icon-white-right`; + + const response = await request(app).get('/badge/stats/projects-count'); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toMatch(/image\/svg\+xml/); + expect(response.text).toBe(expectedSvg); + + expect(prisma.project.count).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).toHaveBeenCalledTimes(1); + expect(mockGenerateBadgeSvg).toHaveBeenCalledWith( + 'Projets', + '7', + '4CAF50', + '2F4F4F', + undefined, + 'white', + 'right' + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/backend/tests/routes/dynamic-image.test.ts b/backend/tests/routes/dynamic-image.test.ts new file mode 100644 index 00000000..6783598d --- /dev/null +++ b/backend/tests/routes/dynamic-image.test.ts @@ -0,0 +1,68 @@ +import request from 'supertest'; +import express from 'express'; +import * as path from 'path'; +import { captchaImageMap } from '../../src/CaptchaMap'; + +// ✅ Mock partiel de 'path' : on garde les vraies fonctions sauf 'join' +jest.mock('path', () => { + const actualPath = jest.requireActual('path'); + return { + ...actualPath, + join: jest.fn(), // Mock seulement join + }; +}); + +// ✅ Mock de captchaImageMap pour test contrôlé +jest.mock('../../src/CaptchaMap', () => ({ + captchaImageMap: {}, +})); + +const app = express(); + +app.get('/dynamic-images/:id', (req, res) => { + const imageId = req.params.id; + const filename = captchaImageMap[imageId]; + if (filename) { + const imagePath = path.join(__dirname, 'images', filename); + res.status(200).send(`Mock image content for ${filename}`); + } else { + res.status(404).send('Image not found'); + } +}); + +describe('GET /dynamic-images/:id', () => { + let mockPathJoin: jest.Mock; + + beforeEach(() => { + mockPathJoin = path.join as jest.Mock; + mockPathJoin.mockClear(); + + for (const key in captchaImageMap) { + delete captchaImageMap[key]; + } + }); + + it('should return 200 and image content if imageId is found', async () => { + const testImageId = 'valid-img-id'; + const testFilename = 'test-image.png'; + captchaImageMap[testImageId] = testFilename; + + mockPathJoin.mockReturnValueOnce(`/mock/path/to/images/${testFilename}`); + + const response = await request(app).get(`/dynamic-images/${testImageId}`); + + expect(response.statusCode).toBe(200); + expect(response.text).toBe(`Mock image content for ${testFilename}`); + expect(mockPathJoin).toHaveBeenCalledWith(expect.any(String), 'images', testFilename); + }); + + it('should return 404 if imageId is not found', async () => { + const testImageId = 'non-existent-img-id'; + + const response = await request(app).get(`/dynamic-images/${testImageId}`); + + expect(response.statusCode).toBe(404); + expect(response.text).toBe('Image not found'); + expect(mockPathJoin).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/backend/tests/routes/upload.test.ts b/backend/tests/routes/upload.test.ts new file mode 100644 index 00000000..d8547e5d --- /dev/null +++ b/backend/tests/routes/upload.test.ts @@ -0,0 +1,100 @@ +import request from 'supertest'; +import express, { Errback, Response } from 'express'; + +// Mock propre de path +jest.mock('path', () => { + const actualPath = jest.requireActual('path'); + return { + ...actualPath, + join: jest.fn(), + }; +}); +import path from 'path'; + +const app = express(); + +app.get('/upload/:type/:filename', (req, res) => { + const { type, filename } = req.params; + + if (!['image', 'video'].includes(type)) { + return res.status(400).send('Invalid type. Use "image" or "video".'); + } + + const filePath = path.join(__dirname, '.', 'uploads', `${type}s`, filename); + + res.sendFile(filePath, (err) => { + if (err && !res.headersSent) { + // The console.error here is fine, it only logs on actual error + console.error(`Fichier non trouvé : ${filePath}`); + return res.status(404).send('Fichier non trouvé'); + } + }); +}); + +describe('GET /upload/:type/:filename', () => { + const mockJoin = path.join as jest.Mock; + let sendFileSpy: jest.SpyInstance; + + beforeEach(() => { + // Spy on sendFile + sendFileSpy = jest + .spyOn(express.response, 'sendFile') + .mockImplementation(function ( + this: Response, + filePath: string, + optionsOrCb?: any, + cb?: Errback + ) { + const callback: Errback | undefined = + typeof optionsOrCb === 'function' ? optionsOrCb : cb; + + // Simulate async call + setImmediate(() => { + if (filePath.includes('missing')) { + // On error, call the callback with an error + callback?.(new Error('Not found')); + } else { + callback?.(null as any); + if (!this.headersSent) { + this.status(200).send('Mock file content from sendFile mock'); + } + } + }); + + return this; + }); + }); + + afterEach(() => { + if (sendFileSpy) sendFileSpy.mockRestore(); + jest.clearAllMocks(); + }); + + it('should return 400 if type is invalid', async () => { + const res = await request(app).get('/upload/invalid/file.png'); + expect(res.statusCode).toBe(400); + expect(res.text).toBe('Invalid type. Use "image" or "video".'); + }); + + it('should return 200 and call sendFile when file exists', async () => { + const filePath = '/mock/path/uploads/images/file.png'; + mockJoin.mockReturnValueOnce(filePath); + + const res = await request(app).get('/upload/image/file.png'); + + expect(res.statusCode).toBe(200); + expect(res.text).toBe('Mock file content from sendFile mock'); + expect(sendFileSpy).toHaveBeenCalledWith(filePath, expect.any(Function)); + }); + + it('should return 404 if file does not exist', async () => { + const filePath = '/mock/path/uploads/images/missing.png'; + mockJoin.mockReturnValueOnce(filePath); + + const res = await request(app).get('/upload/image/missing.png'); + + expect(res.statusCode).toBe(404); + expect(res.text).toBe('Fichier non trouvé'); + expect(sendFileSpy).toHaveBeenCalledWith(filePath, expect.any(Function)); + }); +}); \ No newline at end of file diff --git a/backend/tests/singleton.ts b/backend/tests/singleton.ts new file mode 100644 index 00000000..f1296202 --- /dev/null +++ b/backend/tests/singleton.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; +import { mockDeep } from "jest-mock-extended"; // <-- Utilise jest-mock-extended +// import { vi } from "vitest"; // <-- Supprime ou commente cette ligne si tu n'utilises pas vi + +export const prismaMock = mockDeep(); \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index d11d119a..e887d78f 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ + "types": ["jest", "jest-mock-extended"], /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ diff --git a/docker-compose.yml b/docker-compose.yml index 6b13f8c8..8c5868fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,31 +10,17 @@ services: - WDS_SOCKET_HOST=127.0.0.1 - CHOKIDAR_USEPOLLING=true - WATCHPACK_POLLING=true - depends_on: - db: - condition: service_healthy + env_file: - ./backend/.env - db: - image: postgres:15 - ports: - - 5432:5432 - restart: always - healthcheck: - test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"] - interval: 5s - timeout: 5s - retries: 10 - environment: - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB=${POSTGRES_DB} - env_file: - - ./.env - volumes: - - portfolio-data:/var/lib/postgresql/data + frontend: - build: ./frontend + # build: ./frontend + build: + context: ./frontend + args: + NEXT_PUBLIC_API_TOKEN: ${NEXT_PUBLIC_API_TOKEN} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} ports: - 3000:3000 volumes: @@ -45,6 +31,7 @@ services: - WATCHPACK_POLLING=true - NEXT_PUBLIC_IMAGE_URL=http://localhost:8000 - NEXT_PUBLIC_API_TOKEN=${NEXT_PUBLIC_API_TOKEN} + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} - API_URL=${API_URL} - NODE_ENV=development env_file: diff --git a/frontend/.env.sample b/frontend/.env.sample index 4af5b446..f139ba9e 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -1,3 +1,4 @@ isProduction="true and false" NEXT_DISABLE_HMR="true and false" NEXT_PUBLIC_API_TOKEN="" +NEXT_PUBLIC_API_URL="" \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 14edc7fa..b6879ac4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,10 +11,16 @@ COPY public public COPY next.config.mjs . COPY tsconfig.json . -CMD npm run dev -# RUN npm run build -# CMD npm run start +ARG NEXT_PUBLIC_API_TOKEN +ENV NEXT_PUBLIC_API_TOKEN=${NEXT_PUBLIC_API_TOKEN} +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} + +# CMD npm run dev + +RUN npm run build +CMD npm run start # RUN if [ "$NODE_ENV" = "production" ]; then npm run build; fi # CMD if [ "$NODE_ENV" = "production" ]; then npm run start; else npm run dev; fi \ No newline at end of file diff --git a/frontend/codegen.ts b/frontend/codegen.ts deleted file mode 100644 index 550ad1fb..00000000 --- a/frontend/codegen.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CodegenConfig } from "@graphql-codegen/cli"; - -const config: CodegenConfig = { - schema: "http://localhost:4000/graphql", - documents: ['src/requetes/queries/*.queries.ts', 'src/requetes/mutations/*.mutations.ts'], - generates: { - "./src/types/graphql.ts": { - config: { - useIndexSignature: true, - // maybeValue: "T | undefined", - }, - plugins: [ - "typescript", - "typescript-operations", - "typescript-react-apollo", - ], - }, - }, - // debug : true, - verbose : true -}; -export default config; \ No newline at end of file diff --git a/frontend/codegen.yml b/frontend/codegen.yml new file mode 100644 index 00000000..a921445e --- /dev/null +++ b/frontend/codegen.yml @@ -0,0 +1,19 @@ +verbose: true + +schema: + - http://localhost:4000/graphql: + headers: + x-api-key: "${NEXT_PUBLIC_API_TOKEN}" + +documents: + - src/requetes/queries/*.queries.ts + - src/requetes/mutations/*.mutations.ts + +generates: + ./src/types/graphql.ts: + plugins: + - typescript + - typescript-operations + - typescript-react-apollo + config: + useIndexSignature: true \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd58073b..53638893 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,6 +45,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/react-redux": "^7.1.33", + "dotenv-cli": "^8.0.0", "eslint": "^8", "eslint-config-next": "14.2.3", "postcss": "^8", @@ -5161,10 +5162,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5441,6 +5443,32 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/dset": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f2288dcb..e724873f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "generate": "graphql-codegen --watch", + "generate": "dotenv -e .env -- graphql-codegen --config codegen.yml --watch", + "generatesave": "graphql-codegen --watch", "lint": "next lint" }, "dependencies": { @@ -47,6 +48,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "@types/react-redux": "^7.1.33", + "dotenv-cli": "^8.0.0", "eslint": "^8", "eslint-config-next": "14.2.3", "postcss": "^8", diff --git a/frontend/src/components/Captcha/Captcha.tsx b/frontend/src/components/Captcha/Captcha.tsx index 9520109f..10386a4f 100644 --- a/frontend/src/components/Captcha/Captcha.tsx +++ b/frontend/src/components/Captcha/Captcha.tsx @@ -97,6 +97,7 @@ const CaptchaModal: React.FC = ({ const imageUrls = response.data.generateCaptcha.images.map( (img) => img.url ); + console.log("imageUrls", imageUrls) preloadImages(imageUrls).then(() => { setImages(response.data.generateCaptcha.images); setChallengeType(response.data.generateCaptcha.challengeType); @@ -108,6 +109,7 @@ const CaptchaModal: React.FC = ({ } }) .catch((error) => { + console.log("error", error); showAlert("error", getErrorMessage(error)); setLoading(false); setCheckRefresh(false); @@ -127,6 +129,7 @@ const CaptchaModal: React.FC = ({ generateCaptcha .refetch() .then((response) => { + console.log(response) if (response.data) { const imageUrls = response.data.generateCaptcha.images.map( (img) => img.url diff --git a/frontend/src/components/Careers/Careers.tsx b/frontend/src/components/Careers/Careers.tsx index 08b6d61d..2b425bed 100644 --- a/frontend/src/components/Careers/Careers.tsx +++ b/frontend/src/components/Careers/Careers.tsx @@ -65,35 +65,31 @@ const Careers: React.FC = (): React.ReactElement => { return (
-
    - {combinedData?.map((item, index) => { - return ( -
  1. -
    -
    -

    - {item.startDate} - {item.endDate} -

    -
    -
    -

    {item.type}

    -

    - {(item as EducationType)?.title}{" "} - {(item as ExperienceType)?.job} -

    -

    - {(item as EducationType).diplomaLevel}{" "} - {(item as ExperienceType).employmentContract} -

    -

    - {(item as EducationType).school}{" "} - {(item as ExperienceType).business} -

    -
    -
  2. - ); - })} -
+
    + {combinedData?.map((item, index) => ( +
  1. +
    +
    +

    + {item.startDate} - {item.endDate} +

    +
    +
    +

    {item.type}

    +

    + {(item as EducationType)?.title} {(item as ExperienceType)?.job} +

    +

    + {(item as EducationType).diplomaLevel}{" "} + {(item as ExperienceType).employmentContract} +

    +

    + {(item as EducationType).school} {(item as ExperienceType).business} +

    +
    +
  2. + ))} +
); }; diff --git a/frontend/src/components/Projects/Projects.tsx b/frontend/src/components/Projects/Projects.tsx index 7836e32f..8c6e21b5 100644 --- a/frontend/src/components/Projects/Projects.tsx +++ b/frontend/src/components/Projects/Projects.tsx @@ -23,6 +23,7 @@ const Projects: React.FC = ({ const { translations } = useLang(); useEffect(() => { + console.log("process.env.NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL) if (project) { setIsClient(true); } @@ -62,7 +63,7 @@ const Projects: React.FC = ({
{isClient && ( = ({ ) : (
{project?.title} diff --git a/frontend/src/components/Terminal/components/Commands/ProjectsCommand.tsx b/frontend/src/components/Terminal/components/Commands/ProjectsCommand.tsx index 1ff4a3c0..5ee17937 100644 --- a/frontend/src/components/Terminal/components/Commands/ProjectsCommand.tsx +++ b/frontend/src/components/Terminal/components/Commands/ProjectsCommand.tsx @@ -122,7 +122,7 @@ const ProjectsCommand: React.FC = () => {
{isClient && ( {
) : ( Card Image diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 41590696..808e7a74 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -54,7 +54,7 @@ const App = ({ Component, pageProps }: AppProps): React.ReactElement => { }, []); if (!client) { - return ; // ou un spinner de chargement + return ; } return ( diff --git a/frontend/src/pages/_document.tsx b/frontend/src/pages/_document.tsx index 86da6531..552f6a2d 100644 --- a/frontend/src/pages/_document.tsx +++ b/frontend/src/pages/_document.tsx @@ -23,7 +23,7 @@ const Document = (): React.ReactElement => { j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-MT75FPT5'); - `} + `} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 533c8502..4180ef8a 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect } from "react"; import { useLang } from "@/context/Lang/LangContext"; import { useSectionRefs } from "@/context/SectionRefs/SectionRefsContext"; @@ -10,15 +12,28 @@ import Terminal from "@/components/Terminal/Terminal"; import { useChoiceView } from "@/context/ChoiceView/ChoiceViewContext"; import { useDispatch, useSelector } from "react-redux"; import { RootState, AppDispatch } from "@/store/store"; -import { updateSkillCategories } from "@/store/slices/skillsSlice"; -import { updateProjectDescriptions } from "@/store/slices/projectsSlice"; -import { updateEducationsTitle } from "@/store/slices/educationsSlice"; -import { updateExperiences } from "@/store/slices/experiencesSlice"; +import { setSkills, updateSkillCategories } from "@/store/slices/skillsSlice"; +import { updateProjectDescriptions, setProjects} from "@/store/slices/projectsSlice"; +import { setEducations, updateEducationsTitle } from "@/store/slices/educationsSlice"; +import { setExperiences, updateExperiences } from "@/store/slices/experiencesSlice"; import Seo from "@/components/Seo/Seo"; import Educations from "@/components/Careers/Careers"; import Contact from "@/components/Contact/Contact"; +import { + useGetProjectsListQuery, + useGetSkillsListQuery, + useGetEducationsListQuery, + useGetExperiencesListQuery, +} from "@/types/graphql"; + const Home: React.FC = (): React.ReactElement => { + + const projectsData = useGetProjectsListQuery(); + const skillsData = useGetSkillsListQuery(); + const educationsData = useGetEducationsListQuery(); + const experiencesData = useGetExperiencesListQuery(); + const { translations } = useLang(); const { aboutMeRef, @@ -35,6 +50,89 @@ const Home: React.FC = (): React.ReactElement => { const dataProjects = useSelector( (state: RootState) => state.projects.dataProjects ); + const dataEducations = useSelector( + (state: RootState) => state.educations.dataEducations + ); + const dataExperiences = useSelector( + (state: RootState) => state.experiences.dataExperiences + ); + + useEffect(() => { + const responseProject = projectsData.data?.projectList; + + if (responseProject?.code === 200 && responseProject.projects && dataProjects.length === 0) { + const formattedProjects = responseProject.projects.map((project) => ({ + ...project, + id: Number(project.id), + github: project.github ?? null, + description: + translations.file === "fr" ? project.descriptionFR : project.descriptionEN, + })); + dispatch(setProjects(formattedProjects)); + } + + const responseSkill = skillsData.data?.skillList; + + if (responseSkill?.code === 200 && responseSkill.categories && dataSkills.length === 0) { + const formattedSkills = responseSkill.categories.map((skill) => ({ + ...skill, + id: Number(skill.id), + category: translations.file === "fr" ? skill.categoryFR : skill.categoryEN, + })); + dispatch(setSkills(formattedSkills)); + } + + const responseEducation = educationsData.data?.educationList; + + if (responseEducation?.code === 200 && responseEducation.educations && dataEducations.length === 0) { + const formattedEducation = responseEducation.educations.map((edu) => ({ + ...edu, + id: parseInt(edu.id, 10), + month: edu.month ?? null, + title: translations.file === "fr" ? edu.titleFR : edu.titleEN, + diplomaLevel: + translations.file === "fr" ? edu.diplomaLevelFR : edu.diplomaLevelEN, + startDate: + translations.file === "fr" ? edu.startDateFR : edu.startDateEN, + endDate: translations.file === "fr" ? edu.endDateFR : edu.endDateEN, + type: translations.file === "fr" ? edu.typeFR : edu.typeEN, + })); + dispatch(setEducations(formattedEducation)); + } + + const responseExperience = experiencesData.data?.experienceList; + + if (responseExperience?.code === 200 && responseExperience.experiences && dataExperiences.length === 0) { + const formattedExperience = responseExperience.experiences.map((exp) => ({ + ...exp, + id: parseInt(exp.id, 10), + month: exp.month ?? null, + employmentContractEN: exp.employmentContractEN ?? null, + employmentContractFR: exp.employmentContractFR ?? null, + job: translations.file === "fr" ? exp.jobFR : exp.jobEN, + employmentContract: + translations.file === "fr" + ? exp.employmentContractFR + : exp.employmentContractEN, + startDate: + translations.file === "fr" ? exp.startDateFR : exp.startDateEN, + endDate: translations.file === "fr" ? exp.endDateFR : exp.endDateEN, + type: translations.file === "fr" ? exp.typeFR : exp.typeEN, + })); + dispatch(setExperiences(formattedExperience)); + } + }, [ + educationsData, + experiencesData, + projectsData, + skillsData, + dispatch, + translations, + dataSkills, + dataEducations, + dataProjects, + dataExperiences, + ]); useEffect(() => { dispatch(updateSkillCategories(translations.file)); diff --git a/frontend/src/requetes/queries/educations.queries.ts b/frontend/src/requetes/queries/educations.queries.ts new file mode 100644 index 00000000..3306a6bb --- /dev/null +++ b/frontend/src/requetes/queries/educations.queries.ts @@ -0,0 +1,27 @@ +import { gql } from "@apollo/client"; + +export const GET_EDUCATIONS_LIST = gql` + query GetEducationsList { + educationList { + message + code + educations { + diplomaLevelEN + diplomaLevelFR + endDateEN + endDateFR + id + location + month + school + startDateEN + startDateFR + titleEN + titleFR + typeEN + typeFR + year + } + } + } +`; \ No newline at end of file diff --git a/frontend/src/requetes/queries/experiences.queries.ts b/frontend/src/requetes/queries/experiences.queries.ts new file mode 100644 index 00000000..ba960a50 --- /dev/null +++ b/frontend/src/requetes/queries/experiences.queries.ts @@ -0,0 +1,25 @@ +import { gql } from "@apollo/client"; + +export const GET_EXPERIENCES_LIST = gql` + query GetExperiencesList { + experienceList { + message + code + experiences { + employmentContractEN + business + employmentContractFR + endDateEN + endDateFR + jobEN + id + jobFR + month + startDateEN + startDateFR + typeEN + typeFR + } + } + } +`; \ No newline at end of file diff --git a/frontend/src/requetes/queries/projects.queries.ts b/frontend/src/requetes/queries/projects.queries.ts new file mode 100644 index 00000000..2cddf1e6 --- /dev/null +++ b/frontend/src/requetes/queries/projects.queries.ts @@ -0,0 +1,25 @@ +import { gql } from "@apollo/client"; + +export const GET_PROJECTS_LIST = gql` + query GetProjectsList { + projectList { + message + code + projects { + contentDisplay + descriptionEN + descriptionFR + github + id + skills { + categoryId + id + image + name + } + title + typeDisplay + } + } + } +`; \ No newline at end of file diff --git a/frontend/src/requetes/queries/skills.queries.ts b/frontend/src/requetes/queries/skills.queries.ts new file mode 100644 index 00000000..c97c81cb --- /dev/null +++ b/frontend/src/requetes/queries/skills.queries.ts @@ -0,0 +1,21 @@ +import { gql } from "@apollo/client"; + +export const GET_SKILLS_LIST = gql` + query GetSkillsList { + skillList { + categories { + categoryFR + id + skills { + categoryId + id + image + name + } + categoryEN + } + code + message + } + } +`; \ No newline at end of file diff --git a/frontend/src/store/slices/educationsSlice.tsx b/frontend/src/store/slices/educationsSlice.tsx index 41c3f03f..6b73c0c5 100644 --- a/frontend/src/store/slices/educationsSlice.tsx +++ b/frontend/src/store/slices/educationsSlice.tsx @@ -1,5 +1,4 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { educationsData } from "@/Data/educationsData"; export type EducationType = { id: number; @@ -18,7 +17,7 @@ export type EducationType = { endDateEN: string; endDateFR: string; endDate?: string; - month: number | null; + month: number | null | undefined; typeEN: string; typeFR: string; type?: string; @@ -29,7 +28,7 @@ type EducationsState = { }; const initialState: EducationsState = { - dataEducations: educationsData, + dataEducations: [], }; const educationsSlice = createSlice({ @@ -41,7 +40,7 @@ const educationsSlice = createSlice({ }, updateEducationsTitle(state, action: PayloadAction) { const lang = action.payload; - state.dataEducations = educationsData.map((education) => ({ + state.dataEducations = state.dataEducations.map((education) => ({ ...education, title: lang === "fr" ? education.titleFR : education.titleEN, diplomaLevel: @@ -56,4 +55,4 @@ const educationsSlice = createSlice({ }); export const { setEducations, updateEducationsTitle } = educationsSlice.actions; -export default educationsSlice.reducer; +export default educationsSlice.reducer; \ No newline at end of file diff --git a/frontend/src/store/slices/experiencesSlice.tsx b/frontend/src/store/slices/experiencesSlice.tsx index c8ffcfd7..847c0038 100644 --- a/frontend/src/store/slices/experiencesSlice.tsx +++ b/frontend/src/store/slices/experiencesSlice.tsx @@ -1,5 +1,4 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { experiencesData } from "@/Data/experiencesData"; export type ExperienceType = { id: number; @@ -7,8 +6,8 @@ export type ExperienceType = { jobFR: string; job?: string; business: string; - employmentContractEN: string | null; - employmentContractFR: string | null; + employmentContractEN?: string | null; + employmentContractFR?: string | null; employmentContract?: string | null; startDateEN: string; startDateFR: string; @@ -16,7 +15,7 @@ export type ExperienceType = { endDateEN: string; endDateFR: string; endDate?: string; - month: number | null; + month: number | null | undefined; typeEN: string; typeFR: string; type?: string; @@ -27,7 +26,7 @@ type ExperiencesState = { }; const initialState: ExperiencesState = { - dataExperiences: experiencesData, + dataExperiences: [], }; const experiencesSlice = createSlice({ @@ -39,7 +38,7 @@ const experiencesSlice = createSlice({ }, updateExperiences(state, action: PayloadAction) { const lang = action.payload; - state.dataExperiences = experiencesData.map((experience) => ({ + state.dataExperiences = state.dataExperiences.map((experience) => ({ ...experience, job: lang === "fr" ? experience.jobFR : experience.jobEN, employmentContract: @@ -56,4 +55,4 @@ const experiencesSlice = createSlice({ }); export const { setExperiences, updateExperiences } = experiencesSlice.actions; -export default experiencesSlice.reducer; +export default experiencesSlice.reducer; \ No newline at end of file diff --git a/frontend/src/store/slices/projectsSlice.tsx b/frontend/src/store/slices/projectsSlice.tsx index 8adddcbf..b27046e4 100644 --- a/frontend/src/store/slices/projectsSlice.tsx +++ b/frontend/src/store/slices/projectsSlice.tsx @@ -1,5 +1,4 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { projectsData } from "@/Data/projectsData"; type SkillsProject = { name: string; @@ -22,7 +21,7 @@ type ProjectsState = { }; const initialState: ProjectsState = { - dataProjects: projectsData, + dataProjects: [], }; const projectsSlice = createSlice({ @@ -34,17 +33,14 @@ const projectsSlice = createSlice({ }, updateProjectDescriptions(state, action: PayloadAction) { const lang = action.payload; - state.dataProjects = projectsData - ?.slice() - .reverse() - .map((project) => ({ - ...project, - description: - lang === "fr" ? project.descriptionFR : project.descriptionEN, - })); + state.dataProjects = state.dataProjects.map((project) => ({ + ...project, + description: + lang === "fr" ? project.descriptionFR : project.descriptionEN, + })) as any; }, }, }); export const { setProjects, updateProjectDescriptions } = projectsSlice.actions; -export default projectsSlice.reducer; +export default projectsSlice.reducer; \ No newline at end of file diff --git a/frontend/src/store/slices/skillsSlice.tsx b/frontend/src/store/slices/skillsSlice.tsx index e0407e53..f0830d5f 100644 --- a/frontend/src/store/slices/skillsSlice.tsx +++ b/frontend/src/store/slices/skillsSlice.tsx @@ -1,5 +1,4 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { skillsData } from "@/Data/skillsData"; type SkillSubItem = { name: string; @@ -19,7 +18,7 @@ type SkillsState = { }; const initialState: SkillsState = { - dataSkills: skillsData, + dataSkills: [], }; const skillsSlice = createSlice({ @@ -31,7 +30,7 @@ const skillsSlice = createSlice({ }, updateSkillCategories(state, action: PayloadAction) { const lang = action.payload; - state.dataSkills = skillsData.map((skill) => ({ + state.dataSkills = state.dataSkills.map((skill) => ({ ...skill, category: lang === "fr" ? skill.categoryFR : skill.categoryEN, })); @@ -40,4 +39,4 @@ const skillsSlice = createSlice({ }); export const { setSkills, updateSkillCategories } = skillsSlice.actions; -export default skillsSlice.reducer; +export default skillsSlice.reducer; \ No newline at end of file diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index a871195b..70722ae8 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -1,32 +1,1405 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +/* +! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.inset-0 { + inset: 0px; +} + +.inset-y-0 { + top: 0px; + bottom: 0px; +} + +.left-0 { + left: 0px; +} + +.left-1\/2 { + left: 50%; +} + +.right-0 { + right: 0px; +} + +.top-0 { + top: 0px; +} + +.top-1\/2 { + top: 50%; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.z-40 { + z-index: 40; +} + +.z-50 { + z-index: 50; +} + +.m-1 { + margin: 0.25rem; +} + +.m-2 { + margin: 0.5rem; +} + +.m-3 { + margin: 0.75rem; +} + +.m-4 { + margin: 1rem; +} + +.m-5 { + margin: 1.25rem; +} + +.m-\[3\%\] { + margin: 3%; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.-ms-\[5px\] { + margin-inline-start: -5px; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mb-\[0\.25rem\] { + margin-bottom: 0.25rem; +} + +.mb-\[5\%\] { + margin-bottom: 5%; +} + +.me-3 { + margin-inline-end: 0.75rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-14 { + margin-left: 3.5rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-6 { + margin-left: 1.5rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-3\.5 { + margin-right: 0.875rem; +} + +.mr-4 { + margin-right: 1rem; +} + +.ms-4 { + margin-inline-start: 1rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.mt-8 { + margin-top: 2rem; +} + +.mt-\[-2\%\] { + margin-top: -2%; +} + +.mt-\[1\%\] { + margin-top: 1%; +} + +.mt-\[4\%\] { + margin-top: 4%; +} + +.mt-\[5\%\] { + margin-top: 5%; +} + +.mt-\[10\%\] { + margin-top: 10%; +} + +.mt-\[50\%\] { + margin-top: 50%; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-10 { + margin-top: 2.5rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.hidden { + display: none; +} + +.h-1\/2 { + height: 50%; +} + +.h-40 { + height: 10rem; +} + +.h-52 { + height: 13rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-\[100\%\] { + height: 100%; +} + +.h-\[170px\] { + height: 170px; +} + +.h-\[460px\] { + height: 460px; +} + +.h-\[9px\] { + height: 9px; +} + +.h-\[calc\(100vh-xpx\)\] { + height: calc(100vh - xpx); +} + +.h-auto { + height: auto; +} + +.h-full { + height: 100%; +} + +.h-screen { + height: 100vh; +} + +.max-h-0 { + max-height: 0px; +} + +.w-12 { + width: 3rem; +} + +.w-40 { + width: 10rem; +} + +.w-6 { + width: 1.5rem; +} + +.w-64 { + width: 16rem; +} + +.w-\[100\%\] { + width: 100%; +} + +.w-\[100vh\] { + width: 100vh; +} + +.w-\[25px\] { + width: 25px; +} + +.w-\[350px\] { + width: 350px; +} + +.w-\[9px\] { + width: 9px; +} + +.w-full { + width: 100%; +} + +.max-w-7xl { + max-width: 80rem; +} + +.max-w-\[160px\] { + max-width: 160px; +} + +.max-w-\[210px\] { + max-width: 210px; +} + +.max-w-\[310px\] { + max-width: 310px; +} + +.max-w-md { + max-width: 28rem; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.flex-grow { + flex-grow: 1; +} + +.-translate-x-1\/2 { + --tw-translate-x: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-y-1\/2 { + --tw-translate-y: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-full { + --tw-translate-x: 100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.rotate-180 { + --tw-rotate: 180deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.cursor-not-allowed { + cursor: not-allowed; +} + +.cursor-pointer { + cursor: pointer; +} + +.resize { + resize: both; +} + +.flex-row { + flex-direction: row; +} + +.flex-col { + flex-direction: column; +} + +.flex-col-reverse { + flex-direction: column-reverse; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-around { + justify-content: space-around; +} + +.space-x-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.25rem * var(--tw-space-x-reverse)); + margin-left: calc(1.25rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.overflow-y-hidden { + overflow-y: hidden; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.border { + border-width: 1px; +} + +.border-4 { + border-width: 4px; +} + +.border-s { + border-inline-start-width: 1px; +} + +.border-none { + border-style: none; +} + +.border-gray-300 { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.border-primary { + border-color: var(--primary-color); +} + +.border-secondary { + border-color: var(--secondary-color); +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-body { + background-color: var(--body-color); +} + +.bg-footer { + background-color: var(--footer-color); +} + +.bg-gray-300 { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.bg-primary { + background-color: var(--primary-color); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.object-cover { + -o-object-fit: cover; + object-fit: cover; +} + +.p-0 { + padding: 0px; +} + +.p-1 { + padding: 0.25rem; +} + +.p-4 { + padding: 1rem; +} + +.p-5 { + padding: 1.25rem; +} + +.p-6 { + padding: 1.5rem; +} + +.p-8 { + padding: 2rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.pb-2 { + padding-bottom: 0.5rem; +} + +.pb-5 { + padding-bottom: 1.25rem; +} + +.pb-\[0\.25rem\] { + padding-bottom: 0.25rem; +} + +.pt-0 { + padding-top: 0px; +} + +.pt-0\.5 { + padding-top: 0.125rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-3 { + padding-top: 0.75rem; +} + +.text-center { + text-align: center; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.leading-normal { + line-height: 1.5; +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.text-primary { + color: var(--primary-color); +} + +.text-secondary { + color: var(--secondary-color); +} + +.text-text { + color: var(--text-color); +} + +.text-text200 { + color: var(--text200-color); +} + +.text-text300 { + color: var(--text300-color); +} + +.text-textButton { + color: var(--textButton-color); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.opacity-0 { + opacity: 0; +} + +.opacity-100 { + opacity: 1; +} + +.opacity-50 { + opacity: 0.5; +} + +.opacity-\[50\%\] { + opacity: 50%; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-inner { + --tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.outline { + outline-style: solid; +} + +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-300 { + transition-duration: 300ms; +} + +.duration-500 { + transition-duration: 500ms; +} + +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} :root { - --primary-color: #B45852; - --secondary-color: #DFBB5F; - --scrollHandle-color: #19252E; - --scrollHandleHover-color: #162028; - --body-color: #01031B; - --grey-color: #7F7F7F; - --placeholder-color: #A0AEC0; - --text-color: #ffffff; - --text100-color: #030303; - --text200-color: #B2BDCC; - --text300-color: #64748b; - --textButton-color: white; - --success-color: #1C8036; - --error-color: #AA2020; - --warn-color: #EBCC2A; - --info-color: #3B89FF; - --footer-color: #050F1A; + --primary-color: #B45852; + --secondary-color: #DFBB5F; + --scrollHandle-color: #19252E; + --scrollHandleHover-color: #162028; + --body-color: #01031B; + --grey-color: #7F7F7F; + --placeholder-color: #A0AEC0; + --text-color: #ffffff; + --text100-color: #030303; + --text200-color: #B2BDCC; + --text300-color: #64748b; + --textButton-color: white; + --success-color: #1C8036; + --error-color: #AA2020; + --warn-color: #EBCC2A; + --info-color: #3B89FF; + --footer-color: #050F1A; } body { background-color: var(--body-color); } - /* .custom-scrollbar::-webkit-scrollbar { display: none; color: red; @@ -38,57 +1411,63 @@ body { } */ /* Cacher la barre de défilement par défaut */ + .custom-scrollbar { - overflow-y: hidden; /* Désactiver le défilement vertical */ + overflow-y: hidden; + /* Désactiver le défilement vertical */ } .custom-scrollbar:hover { - overflow-y: auto; /* Activer le défilement vertical au survol */ + overflow-y: auto; + /* Activer le défilement vertical au survol */ } .custom-scrollbar::-webkit-scrollbar { - width: 0; /* Masquer la barre de défilement */ + width: 0; + /* Masquer la barre de défilement */ height: 0; } .custom-scrollbar:hover::-webkit-scrollbar { - width: 2px; /* Afficher la barre de défilement au survol */ + width: 2px; + /* Afficher la barre de défilement au survol */ height: 2px; } - @keyframes expandOpen { - from { - opacity: 0; - max-height: 0; - } - to { - opacity: 1; - max-height: 500px; - } - } - - @keyframes expandClose { - from { - opacity: 1; - max-height: 500px; - } - to { - opacity: 0; - max-height: 0; - } - } - - .expanded-animation-open { - animation: expandOpen 1.0s ease-in-out forwards; - } - - .expanded-animation-close { - animation: expandClose 1.0s ease-in-out forwards; - } - - .MuiCardContent-root { - padding: 0px; + from { + opacity: 0; + max-height: 0; + } + + to { + opacity: 1; + max-height: 500px; + } +} + +@keyframes expandClose { + from { + opacity: 1; + max-height: 500px; + } + + to { + opacity: 0; + max-height: 0; + } +} + +.expanded-animation-open { + animation: expandOpen 1.0s ease-in-out forwards; +} + +.expanded-animation-close { + animation: expandClose 1.0s ease-in-out forwards; +} + +.MuiCardContent-root { + padding: 0px; } ::-webkit-scrollbar { @@ -140,7 +1519,8 @@ body { .custom-video { width: 310px; height: 170px; - object-fit: cover; + -o-object-fit: cover; + object-fit: cover; } .Toastify__toast-container { @@ -172,4 +1552,179 @@ body { .Toastify__toast--info .Toastify__close-button, .Toastify__toast--warn .Toastify__close-button { color: var(--text-button-color); +} + +.hover\:bg-secondary:hover { + background-color: var(--secondary-color); +} + +.hover\:text-secondary:hover { + color: var(--secondary-color); +} + +.hover\:text-text100:hover { + color: var(--text100-color); +} + +.hover\:opacity-\[75\%\]:hover { + opacity: 75%; +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +@media (min-width: 640px) { + .sm\:ml-3 { + margin-left: 0.75rem; + } + + .sm\:max-w-\[80\%\] { + max-width: 80%; + } + + .sm\:max-w-\[90\%\] { + max-width: 90%; + } +} + +@media (min-width: 768px) { + .md\:-mt-\[5px\] { + margin-top: -5px; + } + + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:me-0 { + margin-inline-end: 0px; + } + + .md\:ml-4 { + margin-left: 1rem; + } + + .md\:ms-0 { + margin-inline-start: 0px; + } + + .md\:block { + display: block; + } + + .md\:inline-block { + display: inline-block; + } + + .md\:inline { + display: inline; + } + + .md\:flex { + display: flex; + } + + .md\:hidden { + display: none; + } + + .md\:w-1\/2 { + width: 50%; + } + + .md\:w-\[70\%\] { + width: 70%; + } + + .md\:max-w-\[60\%\] { + max-width: 60%; + } + + .md\:max-w-\[75\%\] { + max-width: 75%; + } + + .md\:justify-center { + justify-content: center; + } + + .md\:gap-6 { + gap: 1.5rem; + } + + .md\:border-s-0 { + border-inline-start-width: 0px; + } + + .md\:border-t { + border-top-width: 1px; + } + + .md\:pt-0 { + padding-top: 0px; + } + + .md\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } +} + +@media (min-width: 1024px) { + .lg\:ml-6 { + margin-left: 1.5rem; + } + + .lg\:block { + display: block; + } + + .lg\:inline-block { + display: inline-block; + } + + .lg\:hidden { + display: none; + } + + .lg\:w-\[70\%\] { + width: 70%; + } + + .lg\:max-w-\[50\%\] { + max-width: 50%; + } + + .lg\:max-w-\[60\%\] { + max-width: 60%; + } + + .lg\:text-6xl { + font-size: 3.75rem; + line-height: 1; + } +} + +@media (min-width: 1280px) { + .xl\:block { + display: block; + } + + .xl\:hidden { + display: none; + } + + .xl\:max-w-\[35\%\] { + max-width: 35%; + } + + .xl\:max-w-\[40\%\] { + max-width: 40%; + } + + .xl\:max-w-\[50\%\] { + max-width: 50%; + } } \ No newline at end of file diff --git a/frontend/src/types/graphql.ts b/frontend/src/types/graphql.ts index 6d4d2fc6..f5d2ac70 100644 --- a/frontend/src/types/graphql.ts +++ b/frontend/src/types/graphql.ts @@ -15,6 +15,29 @@ export type Scalars = { Boolean: { input: boolean; output: boolean; } Int: { input: number; output: number; } Float: { input: number; output: number; } + DateTimeISO: { input: any; output: any; } +}; + +export type BackupFileInfo = { + __typename?: 'BackupFileInfo'; + createdAt: Scalars['DateTimeISO']['output']; + fileName: Scalars['String']['output']; + modifiedAt: Scalars['DateTimeISO']['output']; + sizeBytes: Scalars['Int']['output']; +}; + +export type BackupFilesResponse = { + __typename?: 'BackupFilesResponse'; + code: Scalars['Int']['output']; + files?: Maybe>; + message: Scalars['String']['output']; +}; + +export type BackupResponse = { + __typename?: 'BackupResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + path: Scalars['String']['output']; }; export type CaptchaImage = { @@ -34,6 +57,13 @@ export type CaptchaResponse = { images: Array; }; +export type CategoryResponse = { + __typename?: 'CategoryResponse'; + categories?: Maybe>; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; +}; + export type ChallengeTypeTranslation = { __typename?: 'ChallengeTypeTranslation'; typeEN: Scalars['String']['output']; @@ -46,6 +76,161 @@ export type ContactFrom = { object: Scalars['String']['input']; }; +export type CreateCategoryInput = { + categoryEN: Scalars['String']['input']; + categoryFR: Scalars['String']['input']; +}; + +export type CreateEducationInput = { + diplomaLevelEN: Scalars['String']['input']; + diplomaLevelFR: Scalars['String']['input']; + endDateEN: Scalars['String']['input']; + endDateFR: Scalars['String']['input']; + location: Scalars['String']['input']; + month: Scalars['Int']['input']; + school: Scalars['String']['input']; + startDateEN: Scalars['String']['input']; + startDateFR: Scalars['String']['input']; + titleEN: Scalars['String']['input']; + titleFR: Scalars['String']['input']; + typeEN: Scalars['String']['input']; + typeFR: Scalars['String']['input']; + year: Scalars['Int']['input']; +}; + +export type CreateExperienceInput = { + business: Scalars['String']['input']; + employmentContractEN: Scalars['String']['input']; + employmentContractFR: Scalars['String']['input']; + endDateEN: Scalars['String']['input']; + endDateFR: Scalars['String']['input']; + jobEN: Scalars['String']['input']; + jobFR: Scalars['String']['input']; + month: Scalars['Float']['input']; + startDateEN: Scalars['String']['input']; + startDateFR: Scalars['String']['input']; + typeEN: Scalars['String']['input']; + typeFR: Scalars['String']['input']; +}; + +export type CreateProjectInput = { + contentDisplay: Scalars['String']['input']; + descriptionEN: Scalars['String']['input']; + descriptionFR: Scalars['String']['input']; + github?: InputMaybe; + skillIds: Array; + title: Scalars['String']['input']; + typeDisplay: Scalars['String']['input']; +}; + +export type CreateSkillInput = { + categoryId: Scalars['Int']['input']; + image: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + +export type CreateUserInput = { + email: Scalars['String']['input']; + firstname: Scalars['String']['input']; + lastname: Scalars['String']['input']; + role: Scalars['String']['input']; +}; + +export type Education = { + __typename?: 'Education'; + diplomaLevelEN: Scalars['String']['output']; + diplomaLevelFR: Scalars['String']['output']; + endDateEN: Scalars['String']['output']; + endDateFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + location: Scalars['String']['output']; + month?: Maybe; + school: Scalars['String']['output']; + startDateEN: Scalars['String']['output']; + startDateFR: Scalars['String']['output']; + titleEN: Scalars['String']['output']; + titleFR: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; + year: Scalars['Int']['output']; +}; + +export type EducationResponse = { + __typename?: 'EducationResponse'; + code: Scalars['Int']['output']; + education?: Maybe; + message: Scalars['String']['output']; +}; + +export type EducationsResponse = { + __typename?: 'EducationsResponse'; + code: Scalars['Int']['output']; + educations?: Maybe>; + message: Scalars['String']['output']; +}; + +export type Experience = { + __typename?: 'Experience'; + business: Scalars['String']['output']; + employmentContractEN: Scalars['String']['output']; + employmentContractFR: Scalars['String']['output']; + endDateEN: Scalars['String']['output']; + endDateFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + jobEN: Scalars['String']['output']; + jobFR: Scalars['String']['output']; + month: Scalars['Float']['output']; + startDateEN: Scalars['String']['output']; + startDateFR: Scalars['String']['output']; + typeEN: Scalars['String']['output']; + typeFR: Scalars['String']['output']; +}; + +export type ExperienceResponse = { + __typename?: 'ExperienceResponse'; + code: Scalars['Int']['output']; + experience?: Maybe; + message: Scalars['String']['output']; +}; + +export type ExperiencesResponse = { + __typename?: 'ExperiencesResponse'; + code: Scalars['Int']['output']; + experiences?: Maybe>; + message: Scalars['String']['output']; +}; + +export type GlobalStats = { + __typename?: 'GlobalStats'; + totalEducations: Scalars['Int']['output']; + totalExperiences: Scalars['Int']['output']; + totalProjects: Scalars['Int']['output']; + totalSkills: Scalars['Int']['output']; + totalUsers: Scalars['Int']['output']; + usersByRoleAdmin: Scalars['Int']['output']; + usersByRoleEditor: Scalars['Int']['output']; + usersByRoleView: Scalars['Int']['output']; +}; + +export type GlobalStatsResponse = { + __typename?: 'GlobalStatsResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + stats?: Maybe; +}; + +export type LoginInput = { + email: Scalars['String']['input']; + password: Scalars['String']['input']; +}; + +export type LoginResponse = { + __typename?: 'LoginResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + token?: Maybe; +}; + export type MessageType = { __typename?: 'MessageType'; label: Scalars['String']['output']; @@ -55,32 +240,317 @@ export type MessageType = { export type Mutation = { __typename?: 'Mutation'; + changePassword: Response; clearCaptcha: Scalars['Boolean']['output']; + createCategory: CategoryResponse; + createEducation: EducationResponse; + createExperience: ExperienceResponse; + createProject: ProjectResponse; + createSkill: SubItemResponse; + deleteBackupFile: Response; + deleteCategory: CategoryResponse; + deleteEducation: EducationResponse; + deleteExperience: ExperienceResponse; + deleteProject: Response; + deleteSkill: SubItemResponse; + generateDatabaseBackup: BackupResponse; + login: LoginResponse; + logout: Response; + registerUser: UserResponse; sendContact: MessageType; + updateCategory: CategoryResponse; + updateEducation: EducationResponse; + updateExperience: ExperienceResponse; + updateProject: ProjectResponse; + updateSkill: SubItemResponse; validateCaptcha: ValidationResponse; }; +export type MutationChangePasswordArgs = { + email: Scalars['String']['input']; + newPassword: Scalars['String']['input']; +}; + + export type MutationClearCaptchaArgs = { idCaptcha: Scalars['String']['input']; }; +export type MutationCreateCategoryArgs = { + data: CreateCategoryInput; +}; + + +export type MutationCreateEducationArgs = { + data: CreateEducationInput; +}; + + +export type MutationCreateExperienceArgs = { + data: CreateExperienceInput; +}; + + +export type MutationCreateProjectArgs = { + data: CreateProjectInput; +}; + + +export type MutationCreateSkillArgs = { + data: CreateSkillInput; +}; + + +export type MutationDeleteBackupFileArgs = { + fileName: Scalars['String']['input']; +}; + + +export type MutationDeleteCategoryArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteEducationArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteExperienceArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteProjectArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationDeleteSkillArgs = { + id: Scalars['Int']['input']; +}; + + +export type MutationLoginArgs = { + data: LoginInput; +}; + + +export type MutationRegisterUserArgs = { + data: CreateUserInput; +}; + + export type MutationSendContactArgs = { data: ContactFrom; }; +export type MutationUpdateCategoryArgs = { + data: UpdateCategoryInput; + id: Scalars['Int']['input']; +}; + + +export type MutationUpdateEducationArgs = { + data: UpdateEducationInput; +}; + + +export type MutationUpdateExperienceArgs = { + data: UpdateExperienceInput; +}; + + +export type MutationUpdateProjectArgs = { + data: UpdateProjectInput; +}; + + +export type MutationUpdateSkillArgs = { + data: UpdateSkillInput; + id: Scalars['Int']['input']; +}; + + export type MutationValidateCaptchaArgs = { challengeType: Scalars['String']['input']; idCaptcha: Scalars['String']['input']; selectedIndices: Array; }; +export type Project = { + __typename?: 'Project'; + contentDisplay: Scalars['String']['output']; + descriptionEN: Scalars['String']['output']; + descriptionFR: Scalars['String']['output']; + github?: Maybe; + id: Scalars['ID']['output']; + skills: Array; + title: Scalars['String']['output']; + typeDisplay: Scalars['String']['output']; +}; + +export type ProjectResponse = { + __typename?: 'ProjectResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + project?: Maybe; +}; + +export type ProjectsResponse = { + __typename?: 'ProjectsResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + projects?: Maybe>; +}; + export type Query = { __typename?: 'Query'; - contact: Scalars['String']['output']; + educationById: EducationResponse; + educationList: EducationsResponse; + experienceById: ExperienceResponse; + experienceList: ExperiencesResponse; generateCaptcha: CaptchaResponse; + getGlobalStats: GlobalStatsResponse; + listBackupFiles: BackupFilesResponse; + projectById: ProjectResponse; + projectList: ProjectsResponse; + skillList: CategoryResponse; + userList: UsersResponse; +}; + + +export type QueryEducationByIdArgs = { + id: Scalars['Int']['input']; +}; + + +export type QueryExperienceByIdArgs = { + id: Scalars['Int']['input']; +}; + + +export type QueryProjectByIdArgs = { + id: Scalars['Int']['input']; +}; + +export type Response = { + __typename?: 'Response'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; +}; + +/** User roles */ +export enum Role { + Admin = 'admin', + Editor = 'editor', + View = 'view' +} + +export type Skill = { + __typename?: 'Skill'; + categoryEN: Scalars['String']['output']; + categoryFR: Scalars['String']['output']; + id: Scalars['ID']['output']; + skills: Array; +}; + +export type SkillSubItem = { + __typename?: 'SkillSubItem'; + categoryId: Scalars['Float']['output']; + id: Scalars['ID']['output']; + image: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type SubItemResponse = { + __typename?: 'SubItemResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + subItems?: Maybe>; +}; + +export type UpdateCategoryInput = { + categoryEN?: InputMaybe; + categoryFR?: InputMaybe; +}; + +export type UpdateEducationInput = { + diplomaLevelEN?: InputMaybe; + diplomaLevelFR?: InputMaybe; + endDateEN?: InputMaybe; + endDateFR?: InputMaybe; + id: Scalars['Int']['input']; + location?: InputMaybe; + month?: InputMaybe; + school?: InputMaybe; + startDateEN?: InputMaybe; + startDateFR?: InputMaybe; + titleEN?: InputMaybe; + titleFR?: InputMaybe; + typeEN?: InputMaybe; + typeFR?: InputMaybe; + year?: InputMaybe; +}; + +export type UpdateExperienceInput = { + business?: InputMaybe; + employmentContractEN?: InputMaybe; + employmentContractFR?: InputMaybe; + endDateEN?: InputMaybe; + endDateFR?: InputMaybe; + id: Scalars['Int']['input']; + jobEN?: InputMaybe; + jobFR?: InputMaybe; + month?: InputMaybe; + startDateEN?: InputMaybe; + startDateFR?: InputMaybe; + typeEN?: InputMaybe; + typeFR?: InputMaybe; +}; + +export type UpdateProjectInput = { + contentDisplay?: InputMaybe; + descriptionEN?: InputMaybe; + descriptionFR?: InputMaybe; + github?: InputMaybe; + id: Scalars['Int']['input']; + skillIds?: InputMaybe>; + title?: InputMaybe; + typeDisplay?: InputMaybe; +}; + +export type UpdateSkillInput = { + categoryId: Scalars['Int']['input']; + image?: InputMaybe; + name?: InputMaybe; +}; + +export type User = { + __typename?: 'User'; + email: Scalars['String']['output']; + firstname: Scalars['String']['output']; + id: Scalars['ID']['output']; + isPasswordChange: Scalars['Boolean']['output']; + lastname: Scalars['String']['output']; + role: Role; +}; + +export type UserResponse = { + __typename?: 'UserResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + user?: Maybe; +}; + +export type UsersResponse = { + __typename?: 'UsersResponse'; + code: Scalars['Int']['output']; + message: Scalars['String']['output']; + users?: Maybe>; }; export type ValidationResponse = { @@ -116,6 +586,26 @@ export type GenerateCaptchaQueryVariables = Exact<{ [key: string]: never; }>; export type GenerateCaptchaQuery = { __typename?: 'Query', generateCaptcha: { __typename?: 'CaptchaResponse', id: string, challengeType: string, images: Array<{ __typename?: 'CaptchaImage', typeEN: string, typeFR: string, url: string, id: string }>, challengeTypeTranslation: { __typename?: 'ChallengeTypeTranslation', typeEN: string, typeFR: string } } }; +export type GetEducationsListQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetEducationsListQuery = { __typename?: 'Query', educationList: { __typename?: 'EducationsResponse', message: string, code: number, educations?: Array<{ __typename?: 'Education', diplomaLevelEN: string, diplomaLevelFR: string, endDateEN: string, endDateFR: string, id: string, location: string, month?: number | null, school: string, startDateEN: string, startDateFR: string, titleEN: string, titleFR: string, typeEN: string, typeFR: string, year: number }> | null } }; + +export type GetExperiencesListQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetExperiencesListQuery = { __typename?: 'Query', experienceList: { __typename?: 'ExperiencesResponse', message: string, code: number, experiences?: Array<{ __typename?: 'Experience', employmentContractEN: string, business: string, employmentContractFR: string, endDateEN: string, endDateFR: string, jobEN: string, id: string, jobFR: string, month: number, startDateEN: string, startDateFR: string, typeEN: string, typeFR: string }> | null } }; + +export type GetProjectsListQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetProjectsListQuery = { __typename?: 'Query', projectList: { __typename?: 'ProjectsResponse', message: string, code: number, projects?: Array<{ __typename?: 'Project', contentDisplay: string, descriptionEN: string, descriptionFR: string, github?: string | null, id: string, title: string, typeDisplay: string, skills: Array<{ __typename?: 'SkillSubItem', categoryId: number, id: string, image: string, name: string }> }> | null } }; + +export type GetSkillsListQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetSkillsListQuery = { __typename?: 'Query', skillList: { __typename?: 'CategoryResponse', code: number, message: string, categories?: Array<{ __typename?: 'Skill', categoryFR: string, id: string, categoryEN: string, skills: Array<{ __typename?: 'SkillSubItem', categoryId: number, id: string, image: string, name: string }> }> | null } }; + export const ValidateCaptchaDocument = gql` mutation ValidateCaptcha($challengeType: String!, $selectedIndices: [Float!]!, $idCaptcha: String!) { @@ -271,4 +761,222 @@ export function useGenerateCaptchaSuspenseQuery(baseOptions?: Apollo.SuspenseQue export type GenerateCaptchaQueryHookResult = ReturnType; export type GenerateCaptchaLazyQueryHookResult = ReturnType; export type GenerateCaptchaSuspenseQueryHookResult = ReturnType; -export type GenerateCaptchaQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GenerateCaptchaQueryResult = Apollo.QueryResult; +export const GetEducationsListDocument = gql` + query GetEducationsList { + educationList { + message + code + educations { + diplomaLevelEN + diplomaLevelFR + endDateEN + endDateFR + id + location + month + school + startDateEN + startDateFR + titleEN + titleFR + typeEN + typeFR + year + } + } +} + `; + +/** + * __useGetEducationsListQuery__ + * + * To run a query within a React component, call `useGetEducationsListQuery` and pass it any options that fit your needs. + * When your component renders, `useGetEducationsListQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetEducationsListQuery({ + * variables: { + * }, + * }); + */ +export function useGetEducationsListQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetEducationsListDocument, options); + } +export function useGetEducationsListLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetEducationsListDocument, options); + } +export function useGetEducationsListSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetEducationsListDocument, options); + } +export type GetEducationsListQueryHookResult = ReturnType; +export type GetEducationsListLazyQueryHookResult = ReturnType; +export type GetEducationsListSuspenseQueryHookResult = ReturnType; +export type GetEducationsListQueryResult = Apollo.QueryResult; +export const GetExperiencesListDocument = gql` + query GetExperiencesList { + experienceList { + message + code + experiences { + employmentContractEN + business + employmentContractFR + endDateEN + endDateFR + jobEN + id + jobFR + month + startDateEN + startDateFR + typeEN + typeFR + } + } +} + `; + +/** + * __useGetExperiencesListQuery__ + * + * To run a query within a React component, call `useGetExperiencesListQuery` and pass it any options that fit your needs. + * When your component renders, `useGetExperiencesListQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetExperiencesListQuery({ + * variables: { + * }, + * }); + */ +export function useGetExperiencesListQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetExperiencesListDocument, options); + } +export function useGetExperiencesListLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetExperiencesListDocument, options); + } +export function useGetExperiencesListSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetExperiencesListDocument, options); + } +export type GetExperiencesListQueryHookResult = ReturnType; +export type GetExperiencesListLazyQueryHookResult = ReturnType; +export type GetExperiencesListSuspenseQueryHookResult = ReturnType; +export type GetExperiencesListQueryResult = Apollo.QueryResult; +export const GetProjectsListDocument = gql` + query GetProjectsList { + projectList { + message + code + projects { + contentDisplay + descriptionEN + descriptionFR + github + id + skills { + categoryId + id + image + name + } + title + typeDisplay + } + } +} + `; + +/** + * __useGetProjectsListQuery__ + * + * To run a query within a React component, call `useGetProjectsListQuery` and pass it any options that fit your needs. + * When your component renders, `useGetProjectsListQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetProjectsListQuery({ + * variables: { + * }, + * }); + */ +export function useGetProjectsListQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetProjectsListDocument, options); + } +export function useGetProjectsListLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetProjectsListDocument, options); + } +export function useGetProjectsListSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetProjectsListDocument, options); + } +export type GetProjectsListQueryHookResult = ReturnType; +export type GetProjectsListLazyQueryHookResult = ReturnType; +export type GetProjectsListSuspenseQueryHookResult = ReturnType; +export type GetProjectsListQueryResult = Apollo.QueryResult; +export const GetSkillsListDocument = gql` + query GetSkillsList { + skillList { + categories { + categoryFR + id + skills { + categoryId + id + image + name + } + categoryEN + } + code + message + } +} + `; + +/** + * __useGetSkillsListQuery__ + * + * To run a query within a React component, call `useGetSkillsListQuery` and pass it any options that fit your needs. + * When your component renders, `useGetSkillsListQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetSkillsListQuery({ + * variables: { + * }, + * }); + */ +export function useGetSkillsListQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetSkillsListDocument, options); + } +export function useGetSkillsListLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetSkillsListDocument, options); + } +export function useGetSkillsListSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetSkillsListDocument, options); + } +export type GetSkillsListQueryHookResult = ReturnType; +export type GetSkillsListLazyQueryHookResult = ReturnType; +export type GetSkillsListSuspenseQueryHookResult = ReturnType; +export type GetSkillsListQueryResult = Apollo.QueryResult; \ No newline at end of file