diff --git a/.gitignore b/.gitignore index f6a091e..5fea95f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json diff --git a/Dockerfile b/Dockerfile index c3a4084..501482c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,3 @@ -# RUN apk add --no-cache bash -# RUN npm i -g @nestjs/cli typescript ts-node - -# COPY package*.json /tmp/app/ -# RUN cd /tmp/app && npm install - -# COPY . /usr/src/app -# RUN cp -a /tmp/app/node_modules /usr/src/app -# COPY ./wait-for-it.sh /opt/wait-for-it.sh -# COPY ./startup.dev.sh /opt/startup.dev.sh -# RUN sed -i 's/\r//g' /opt/wait-for-it.sh -# RUN sed -i 's/\r//g' /opt/startup.dev.sh - -# WORKDIR /usr/src/app -# RUN cp env-example .env - -# RUN npm run build - -# CMD ["/opt/startup.dev.sh"] # Use the official Node.js 18 image as a base FROM node:18.16.1-alpine @@ -36,20 +17,20 @@ COPY package*.json ./ # Copy the Prisma configuration and migration files # This line copies the "prisma" directory from your project's root into the Docker container's working directory. COPY prisma ./prisma/ - # Install project dependencies RUN npm install # Copy the rest of the application code to the container COPY . . +COPY env-example ./.env +# Expose the PORT environment variable (default to 4000 if not provided) +ENV PORT=4030 +EXPOSE $PORT # Build your Nest.js application RUN npm run build -# Expose the PORT environment variable (default to 4000 if not provided) -ARG PORT=4000 -ENV PORT=$PORT -EXPOSE $PORT + # Start the Nest.js application using the start:prod script CMD ["npm", "run", "start:prod"] diff --git a/README.md b/README.md index 6aef540..8b1fc07 100644 --- a/README.md +++ b/README.md @@ -1 +1,28 @@ -# Microservice boilerplate +# Course Manager + +[Compass Product Flow](https://miro.com/app/board/uXjVMkv3bh4=/?share_link_id=179469421530) +[Compass Services Diagram](https://app.diagrams.net/#G1ZcWAg558z88DcWNC4b2NKt1Q3MAPHSZu) + +## About +This repository is a part of the Compass Marketplace where consumers and purchase courses and upskill their competencies and third party course providers and onboard and add their courses. It handles the backend server dealing with the use cases of the course providers and partially the admin. Particularly, the entire provider flow on the marketplace which would include adding and updating courses and admin use cases of verifying the providers and courses and settling provider wallet balances. +The tech stack used is NestJS with Prisma ORM and PostgreSQL. + +The Course managermodule is dependent on the modules Marketplace portal and Marketplace Wallet Service. + +## Installation +1. Install the necessary package dependencies + `npm i` +2. Set up a PostgreSQL in your local environment +3. Set up the environment variables as suggested in the example file +4. Generate Prisma migrations + `npx prisma migrate dev` + If seed data is required, it can be populated by running + `npx prisma db seed` + or + `npx prisma migrate reset` + (The latter will also reset the database and delete all previous data) + +## Running A Local Development Server +An auto compiled running server can then be initialized using, + `npm run start:dev` +The Swagger API documentation could be found at `YOUR_APP_PORT/api/docs` \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 56df483..a82abcc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,38 +1,24 @@ services: - postgres: - image: postgres:15.3-alpine - ports: - - ${DATABASE_PORT}:5432 - volumes: - - ./.data/db:/var/lib/postgresql/data - environment: - POSTGRES_USER: ${DATABASE_USERNAME} - POSTGRES_PASSWORD: ${DATABASE_PASSWORD} - POSTGRES_DB: ${DATABASE_NAME} - - # maildev: - # build: - # context: . - # dockerfile: maildev.Dockerfile - # ports: - # - ${MAIL_CLIENT_PORT}:1080 - # - ${MAIL_PORT}:1025 - - # adminer: - # image: adminer - # restart: always - # ports: - # - 8080:8080 +# postgres: +# image: postgres:15.3-alpine +# ports: +# - ${DATABASE_PORT}:5432 +# volumes: +# - ./.data/db:/var/lib/postgresql/data +# environment: +# POSTGRES_USER: ${DATABASE_USERNAME} +# POSTGRES_PASSWORD: ${DATABASE_PASSWORD} +# POSTGRES_DB: ${DATABASE_NAME} - # Uncomment to use redis - # redis: - # image: redis:7-alpine - # ports: - # - 6379:6379 - - api: + course_manager: build: context: . dockerfile: Dockerfile ports: - ${APP_PORT}:${APP_PORT} + networks: + - samagra_compass + +networks: + samagra_compass: + external: true \ No newline at end of file diff --git a/env-example b/env-example index b623ba8..0866388 100644 --- a/env-example +++ b/env-example @@ -1,10 +1,22 @@ NODE_ENV=development -APP_PORT=4000 -APP_NAME="Service API" +APP_PORT= +APP_NAME="Course Manager API" API_PREFIX=api DATABASE_USERNAME= DATABASE_PASSWORD= DATABASE_NAME= -DATABASE_PORT=5432 -DATABASE_URL= +TELEMETRY_DATABASE_NAME= +DATABASE_PORT= +DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@172.17.0.1:5432/${DATABASE_NAME}?schema=public +WALLET_SERVICE_URL= +MARKETPLACE_PORTAL_URL= +USER_SERVICE_URL= +USER_SERVICE_TOKEN= +USER_SERVICE_COOKIE= + +# MINIO +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_ENDPOINT= +MINIO_BUCKET_NAME= diff --git a/package-lock.json b/package-lock.json index d51118f..aa5ad6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,25 +9,35 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^3.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", - "@nestjs/swagger": "^7.1.11", + "@nestjs/swagger": "^7.1.14", "@prisma/client": "^5.4.1", + "axios": "^1.6.1", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "lodash": "^4.17.21", + "minio": "^7.1.3", "nestjs-prisma": "^0.22.0", + "pg": "^8.12.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", - "rxjs": "^7.2.0" + "rxjs": "^7.2.0", + "swagger-ui-express": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.13", "@types/jest": "28.1.8", + "@types/lodash": "^4.14.200", + "@types/multer": "^1.4.11", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -1396,6 +1406,58 @@ "node": ">=8" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@nestjs/axios": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.1.tgz", + "integrity": "sha512-VlOZhAGDmOoFdsmewn8AyClAdGpKXQQaY1+3PGB+g6ceurGIdTxZgRX3VXc1T6Zs60PedWjg3A82TDOB05mrzQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "reflect-metadata": "^0.1.12", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.5.0.tgz", @@ -1647,9 +1709,9 @@ } }, "node_modules/@nestjs/mapped-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz", - "integrity": "sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.3.tgz", + "integrity": "sha512-40Zdqg98lqoF0+7ThWIZFStxgzisK6GG22+1ABO4kZiGF/Tu2FE+DYLw+Q9D94vcFWizJ+MSjNN4ns9r6hIGxw==", "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "class-transformer": "^0.4.0 || ^0.5.0", @@ -1701,15 +1763,15 @@ } }, "node_modules/@nestjs/swagger": { - "version": "7.1.13", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.1.13.tgz", - "integrity": "sha512-aHfW0rDZZKTuPVSkxutBCB16lBy5vrsHVoRF5RvPtH7U2cm4Vf+OnfhxKKuG2g2Xocn9sDL+JAyVlY2VN3ytTw==", + "version": "7.1.16", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.1.16.tgz", + "integrity": "sha512-f9KBk/BX9MUKPTj7tQNYJ124wV/jP5W2lwWHLGwe/4qQXixuDOo39zP55HIJ44LE7S04B7BOeUOo9GBJD/vRcw==", "dependencies": { - "@nestjs/mapped-types": "2.0.2", + "@nestjs/mapped-types": "2.0.3", "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "3.2.0", - "swagger-ui-dist": "5.9.0" + "swagger-ui-dist": "5.9.1" }, "peerDependencies": { "@fastify/static": "^6.0.0", @@ -2040,6 +2102,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", @@ -2170,12 +2241,27 @@ "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz", "integrity": "sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "16.18.57", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.57.tgz", @@ -2624,6 +2710,17 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "optional": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2675,6 +2772,17 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -2782,6 +2890,36 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2813,11 +2951,36 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } }, "node_modules/babel-jest": { "version": "28.1.3", @@ -2934,6 +3097,19 @@ } ] }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2966,6 +3142,27 @@ "node": ">= 6" } }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/block-stream2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -3023,6 +3220,11 @@ "node": ">=8" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==" + }, "node_modules/browserslist": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", @@ -3099,6 +3301,14 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3124,12 +3334,13 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3230,6 +3441,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -3375,11 +3594,18 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3426,6 +3652,11 @@ "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3527,7 +3758,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3540,6 +3770,14 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -3572,15 +3810,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3598,6 +3853,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3704,8 +3967,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { "version": "1.0.2", @@ -4257,6 +4519,27 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-xml-parser": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", + "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -4323,6 +4606,14 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -4389,6 +4680,33 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-8.0.0.tgz", @@ -4421,7 +4739,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4476,6 +4793,33 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/fs-monkey": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", @@ -4502,9 +4846,31 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } }, "node_modules/gensync": { "version": "1.0.0-beta.2", @@ -4525,14 +4891,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4631,6 +4997,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4647,6 +5024,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true, "engines": { "node": ">= 0.4.0" } @@ -4659,6 +5037,17 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", @@ -4681,18 +5070,48 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hexoid": { + "node_modules/has-tostringtag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true, + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, @@ -4711,6 +5130,18 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4860,6 +5291,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4878,6 +5324,17 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", @@ -4903,7 +5360,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -4917,6 +5373,20 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4967,6 +5437,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -5707,6 +6191,11 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-stream/-/json-stream-1.0.0.tgz", + "integrity": "sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6025,6 +6514,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minio": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minio/-/minio-7.1.3.tgz", + "integrity": "sha512-xPrLjWkTT5E7H7VnzOjF//xBp9I40jYB4aWhb2xTFopXXfw+Wo82DDWngdUju7Doy3Wk7R8C4LAgwhLHHnf0wA==", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^0.2.13", + "fast-xml-parser": "^4.2.2", + "ipaddr.js": "^2.0.1", + "json-stream": "^1.0.0", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "through2": "^4.0.2", + "web-encoding": "^1.1.5", + "xml": "^1.0.1", + "xml2js": "^0.5.0" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/minipass": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", @@ -6034,6 +6555,34 @@ "node": ">=8" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -6048,8 +6597,7 @@ "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==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { "version": "1.4.4-lts.1", @@ -6216,6 +6764,11 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "dev": true }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -6256,6 +6809,20 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -6277,6 +6844,17 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6547,6 +7125,87 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", + "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6647,6 +7306,41 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6755,6 +7449,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -6787,6 +7486,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7065,6 +7781,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -7118,7 +7839,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -7133,7 +7853,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7144,8 +7863,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -7211,6 +7929,25 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7321,6 +8058,22 @@ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7364,6 +8117,14 @@ "node": ">=10.0.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7394,7 +8155,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7445,6 +8205,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -7528,9 +8293,23 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.9.0.tgz", - "integrity": "sha512-NUHSYoe5XRTk/Are8jPJ6phzBh3l9l33nEyXosM17QInoV95/jng8+PuSGtbD407QoPf93MH3Bkh773OgesJpA==" + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.9.1.tgz", + "integrity": "sha512-5zAx+hUwJb9T3EAntc7TqYkV716CMqG6sZpNlAAMOMWkNXRYxGkN8ADIvD55dQZ10LxN90ZM/TQmN7y1gpICnw==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.0.tgz", + "integrity": "sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } }, "node_modules/symbol-observable": { "version": "4.0.0", @@ -7550,6 +8329,46 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", + "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -7679,6 +8498,27 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -8060,6 +8900,18 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8153,6 +9005,17 @@ "defaults": "^1.0.3" } }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -8248,6 +9111,32 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/windows-release": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", @@ -8345,6 +9234,31 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "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==", + "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/package.json b/package.json index 59374d7..a7b5950 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "npx prisma migrate deploy && node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -27,25 +27,35 @@ "prisma:seed": "npx prisma db seed" }, "dependencies": { + "@nestjs/axios": "^3.0.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", - "@nestjs/swagger": "^7.1.11", + "@nestjs/swagger": "^7.1.14", "@prisma/client": "^5.4.1", + "axios": "^1.6.1", + "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "lodash": "^4.17.21", + "minio": "^7.1.3", "nestjs-prisma": "^0.22.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", - "rxjs": "^7.2.0" + "rxjs": "^7.2.0", + "swagger-ui-express": "^5.0.0", + "pg": "^8.12.0" }, "devDependencies": { "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.13", "@types/jest": "28.1.8", + "@types/lodash": "^4.14.200", + "@types/multer": "^1.4.11", "@types/node": "^16.0.0", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", @@ -83,4 +93,4 @@ "prisma": { "seed": "ts-node prisma/seed.ts" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20231215085146_first/migration.sql b/prisma/migrations/20231215085146_first/migration.sql new file mode 100644 index 0000000..3b20530 --- /dev/null +++ b/prisma/migrations/20231215085146_first/migration.sql @@ -0,0 +1,101 @@ +-- CreateEnum +CREATE TYPE "ProviderStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "WalletType" AS ENUM ('ADMIN', 'PROVIDER', 'CONSUMER'); + +-- CreateEnum +CREATE TYPE "CourseVerificationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "CourseStatus" AS ENUM ('UNARCHIVED', 'ARCHIVED'); + +-- CreateEnum +CREATE TYPE "CourseProgressStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('PURCHASE', 'CREDIT_REQUEST', 'SETTLEMENT'); + +-- CreateTable +CREATE TABLE "Admin" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "image" TEXT, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + + CONSTRAINT "Admin_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Provider" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "orgName" TEXT NOT NULL, + "orgLogo" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "paymentInfo" JSONB, + "status" "ProviderStatus" NOT NULL DEFAULT 'PENDING', + "rejectionReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Provider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Course" ( + "courseId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "courseLink" TEXT NOT NULL, + "imageLink" TEXT NOT NULL, + "credits" INTEGER NOT NULL, + "language" TEXT[], + "competency" JSONB NOT NULL, + "author" TEXT NOT NULL, + "avgRating" DOUBLE PRECISION, + "status" "CourseStatus" NOT NULL DEFAULT 'UNARCHIVED', + "startDate" TIMESTAMP(3), + "endDate" TIMESTAMP(3), + "verificationStatus" "CourseVerificationStatus" NOT NULL DEFAULT 'PENDING', + "cqfScore" INTEGER, + "impactScore" DOUBLE PRECISION, + "rejectionReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Course_pkey" PRIMARY KEY ("courseId") +); + +-- CreateTable +CREATE TABLE "UserCourse" ( + "id" SERIAL NOT NULL, + "userId" UUID NOT NULL, + "courseId" TEXT NOT NULL, + "purchasedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "CourseProgressStatus" NOT NULL DEFAULT 'IN_PROGRESS', + "courseCompletionScore" DOUBLE PRECISION, + "rating" INTEGER, + "feedback" TEXT, + + CONSTRAINT "UserCourse_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Admin_email_key" ON "Admin"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Provider_email_key" ON "Provider"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserCourse_userId_courseId_key" ON "UserCourse"("userId", "courseId"); + +-- AddForeignKey +ALTER TABLE "Course" ADD CONSTRAINT "Course_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserCourse" ADD CONSTRAINT "UserCourse_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("courseId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1c9ebf..c12527c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,9 +12,100 @@ datasource db { // Here goes models -//dummy user model -model User { - id Int @id @default(autoincrement()) - email String @unique - name String? +enum ProviderStatus { + PENDING + VERIFIED + REJECTED +} + +enum WalletType { + ADMIN + PROVIDER + CONSUMER +} + +enum CourseVerificationStatus { + PENDING + ACCEPTED + REJECTED +} + +enum CourseStatus { + UNARCHIVED + ARCHIVED +} + +enum CourseProgressStatus { + IN_PROGRESS + COMPLETED +} + +model Admin { + id String @id @default(uuid()) + name String + image String? + email String @unique + password String +} + +model Provider { + id String @id @default(uuid()) + name String + email String @unique + password String + orgName String + orgLogo String + phone String + paymentInfo Json? + status ProviderStatus @default(PENDING) + courses Course[] + rejectionReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + + +model Course { + courseId String @id @default(uuid()) + providerId String + title String + description String + courseLink String + imageLink String + credits Int + language String[] + competency Json + author String + avgRating Float? + status CourseStatus @default(UNARCHIVED) + startDate DateTime? + endDate DateTime? + verificationStatus CourseVerificationStatus @default(PENDING) + cqfScore Int? + impactScore Float? + rejectionReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + provider Provider @relation(fields: [providerId], references: [id]) + userCourses UserCourse[] +} + +model UserCourse { + id Int @id @default(autoincrement()) + userId String @db.Uuid + courseId String + purchasedAt DateTime @default(now()) + status CourseProgressStatus @default(IN_PROGRESS) + courseCompletionScore Float? + rating Int? + feedback String? + course Course @relation(fields: [courseId], references: [courseId], onDelete: Cascade) + + @@unique([userId, courseId]) +} + +enum TransactionType { + PURCHASE + CREDIT_REQUEST + SETTLEMENT } diff --git a/prisma/scripts/createViewQueries.ts b/prisma/scripts/createViewQueries.ts new file mode 100644 index 0000000..aa88aa3 --- /dev/null +++ b/prisma/scripts/createViewQueries.ts @@ -0,0 +1,96 @@ +export const createViewQueries = [ + ` + DROP VIEW IF EXISTS metrics_3cp_overall; + `, + ` + DROP VIEW IF EXISTS metrics_courses_overall + `, + ` + DROP VIEW IF EXISTS metrics_user_courses + `, + ` + DROP VIEW IF EXISTS user_courses_mapping + `, + ` + DROP VIEW IF EXISTS telemetry_3cp_details + `, + ` + DROP VIEW IF EXISTS telemetry_3cp_courses + `, + ` + CREATE VIEW metrics_3cp_overall AS + SELECT + (SELECT COUNT(*) FROM "Provider" WHERE status = 'VERIFIED') AS "active3CPs", + (SELECT COUNT(*) FROM "Provider" WHERE status = 'PENDING') AS "pending3CPs", + (SELECT COUNT(*) FROM "Provider" WHERE status = 'REJECTED') AS "rejected3CPs", + (SELECT COUNT(*) FROM "Provider") AS "total3CPs" + `, + + ` + CREATE VIEW metrics_courses_overall AS + SELECT + (SELECT COUNT(*) FROM "Course" WHERE "verificationStatus" = 'ACCEPTED') AS "activeCourses", + (SELECT COUNT(*) FROM "Course" WHERE "verificationStatus" = 'PENDING') AS "pendingCourses", + (SELECT COUNT(*) FROM "Course" WHERE "verificationStatus" = 'REJECTED') AS "rejectedCourses", + (SELECT COUNT(*) FROM "Course") AS "totalCourses", + (SELECT SUM("avgRating")/ COUNT(*) FROM "Course" WHERE "avgRating" > 0) AS "avgActiveCourseRating", + (SELECT COUNT(*) FROM "UserCourse" WHERE status = 'COMPLETED') AS "coursesCompleted", + (SELECT COUNT(*) FROM "UserCourse" WHERE status = 'IN_PROGRESS') AS "coursesInProgress" + `, + + ` + CREATE VIEW metrics_user_courses AS + SELECT + uc."userId", + COUNT(DISTINCT uc."courseId") AS "coursesRegisteredCount", + COUNT(*) FILTER (WHERE uc.status = 'IN_PROGRESS') AS "coursesInProgressCount", + COUNT(*) FILTER (WHERE uc.status = 'COMPLETED') AS "coursesCompletedCount" + FROM + "UserCourse" uc + GROUP BY + uc."userId" + `, + ` + CREATE VIEW user_courses_mapping AS + SELECT + uc."userId", + c."courseId", + c.title, + uc.status + FROM + "UserCourse" uc + JOIN + "Course" c ON uc."courseId" = c."courseId" + GROUP BY + uc."userId", c."courseId", uc.status + `, + ` + CREATE VIEW telemetry_3cp_details AS + SELECT + p.id AS "3cpId", + p.name, + p."orgName" AS "organizationName", + p.status, + p."rejectionReason" + FROM + "Provider" p + GROUP BY + p.id, p.name + `, + ` + CREATE VIEW telemetry_3cp_courses AS + SELECT + c."providerId" AS "3cpId", + c."courseId", + c.title AS "courseName", + c."avgRating" AS rating, + c."verificationStatus", + COUNT(DISTINCT uc."userId") AS "endUsersCount" + FROM + "Course" c + LEFT JOIN + "UserCourse" uc ON c."courseId" = uc."courseId" + GROUP BY + c."courseId", c.title + ` +]; \ No newline at end of file diff --git a/prisma/scripts/moveViewsQueries.ts b/prisma/scripts/moveViewsQueries.ts new file mode 100644 index 0000000..1710165 --- /dev/null +++ b/prisma/scripts/moveViewsQueries.ts @@ -0,0 +1,103 @@ +const dbName = process.env.DATABASE_NAME; +const dbUserName = process.env.DATABASE_USERNAME; +const dbPassword = process.env.DATABASE_PASSWORD; +const dbPort = process.env.DATABASE_PORT; +const dbHost = '172.17.0.1'; + +export const copyViewQueries = [ + `CREATE EXTENSION IF NOT EXISTS postgres_fdw`, + ` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_foreign_server + WHERE srvname = 'course_manager_server' + ) THEN + EXECUTE 'CREATE SERVER course_manager_server + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (host ''localhost'', dbname ''course-manager-db'', port ''5432'')'; + END IF; + END $$; + `, + ` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_user_mappings + WHERE srvname = 'course_manager_server' AND usename = 'supercompass' + ) THEN + EXECUTE 'CREATE USER MAPPING FOR supercompass + SERVER course_manager_server + OPTIONS (user ''supercompass'', password ''tCzPKM47gE5ixVjn'')'; + END IF; + END $$; + `, + ` + CREATE FOREIGN TABLE metrics_3cp_overall ( + "active3CPs" BIGINT, + "pending3CPs" BIGINT, + "rejected3CPs" BIGINT, + "total3CPs" BIGINT + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'metrics_3cp_overall'); + `, + ` + CREATE FOREIGN TABLE metrics_courses_overall ( + "activeCourses" bigint, + "pendingCourses" bigint, + "rejectedCourses" bigint, + "totalCourses" bigint, + "avgActiveCourseRating" double precision, + "coursesCompleted" bigint, + "coursesInProgress" bigint + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'metrics_courses_overall'); + `, + ` + CREATE FOREIGN TABLE metrics_user_courses ( + "userId" text, + "coursesRegisteredCount" bigint, + "coursesInProgressCount" bigint, + "coursesCompletedCount" bigint + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'metrics_user_courses'); + `, + ` + CREATE FOREIGN TABLE user_courses_mapping ( + "userId" text, + "courseId" text, + "title" text, + "status" text + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'user_courses_mapping'); + `, + ` + CREATE FOREIGN TABLE telemetry_3cp_details ( + "3cpId" text, + "name" text, + "organizationName" text, + "status" "ProviderStatus", + "rejectionReason" text + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'telemetry_3cp_details'); + `, + ` + CREATE FOREIGN TABLE telemetry_3cp_courses ( + "3cpId" text, + "courseId" text, + "courseName" text, + "rating" double precision, + "verificationStatus" text, + "endUsersCount" bigint + ) + SERVER course_manager_server + OPTIONS (schema_name 'public', table_name 'telemetry_3cp_courses'); + `, +]; \ No newline at end of file diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..b2d9c20 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,694 @@ +import { Logger } from '@nestjs/common'; +import { CourseProgressStatus, CourseStatus, CourseVerificationStatus, Prisma, PrismaClient, ProviderStatus } from '@prisma/client' +import * as bcrypt from 'bcrypt'; +import * as fs from "fs"; +import { createViewQueries } from "./scripts/createViewQueries"; +import { copyViewQueries } from "./scripts/moveViewsQueries" +const prisma = new PrismaClient(); +const { Client } = require('pg'); +const telemetryDbName = process.env.TELEMETRY_DATABASE_NAME; + +async function seed() { + + const saltRounds = 10; + const hashedPassword = await bcrypt.hash("Dilu@123", saltRounds); + const hashedPassword1 = await bcrypt.hash("Favas@456", saltRounds); + const hashedPassword2 = await bcrypt.hash("Udemy@9812", saltRounds); + const hashedPassword3 = await bcrypt.hash("Coursera@999", saltRounds); + const hashedPassword4 = await bcrypt.hash("lern@999", saltRounds); + const provider = await prisma.provider.create({ + data: { + id: "123e4567-e89b-42d3-a456-556642440010", + name: "Vijay Salgaonkar", + email: "vijaysalgaonkar@gmail.com", + password: hashedPassword, + status: ProviderStatus.VERIFIED, + orgLogo: "https://logos-world.net/wp-content/uploads/2021/11/Udemy-Logo.png", + orgName: "NPTEL", + phone: "9999999999", + } + }); + + const provider1 = await prisma.provider.create({ + data: { + id: "123e4567-e89b-42d3-a456-556642440011", + name: "udemy", + email: "udemyorg@gmail.in", + password: hashedPassword2, + paymentInfo: { + bankAccNo: "1111111111", + otherDetails: { + + } + }, + status: ProviderStatus.VERIFIED, + orgLogo: "https://logos-world.net/wp-content/uploads/2021/11/Udemy-Logo.png", + orgName: "Udemy", + phone: "9999999999", + } + }); + + const provider2 = await prisma.provider.create({ + data: { + id: "123e4567-e89b-42d3-a456-556642440012", + name: "coursera", + email: "coursera@gmail.in", + password: hashedPassword3, + paymentInfo: { + bankAccNo: "1111111113", + otherDetails: { + + } + }, + status: ProviderStatus.PENDING, + orgLogo: "https://1000logos.net/wp-content/uploads/2022/06/Coursera-Logo-2012.png", + orgName: "Coursera", + phone: "9999999999", + } + }); + + const provider3 = await prisma.provider.create({ + data: { + id: "123e4567-e89b-42d3-a456-556642440013", + name: "lern", + email: "lern@gmail.in", + password: hashedPassword4, + paymentInfo: { + bankAccNo: "1111111116", + otherDetails: { + + } + }, + status: ProviderStatus.REJECTED, + rejectionReason: "Invalid backAccNo", + orgLogo: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/Logo_of_Twitter.svg/2491px-Logo_of_Twitter.svg.png", + orgName: "Sunbird", + phone: "9999999999", + } + }); + + const response1 = await prisma.course.createMany({ + data: [{ + courseId: "123e4567-e89b-42d3-a456-556642440050", + providerId: provider1.id, + title: "Comprehensive Floor Inspection Techniques", + description: "Develop skills for thorough and accurate floor inspections.", + courseLink: "https://www.udemy.com/course/nestjs-the-complete-developers-guide/", + imageLink: "https://courses.nestjs.com/img/logo.svg", + credits: 4, + language: ["en"], + competency: JSON.stringify([{ + "id": 1, + "name": "Floor Planning and Mapping", + "levels": [ + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 8, + "name": "Floor Inspection", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 7, + "name": "Survey", + "levels": null + } + ]), + author: "Stephen Grider", + startDate: new Date("2023-05-01").toISOString(), + endDate: new Date("2024-07-01").toISOString(), + verificationStatus: CourseVerificationStatus.ACCEPTED, + }, { + courseId: "123e4567-e89b-42d3-a456-556642440051", + providerId: provider1.id, + title: "Advanced Floor Planning and Inspection", + description: "Master the skills of creating detailed floor plans and conducting thorough floor inspections.", + courseLink: "https://www.udemy.com/course/graphic-design-masterclass-everything-you-need-to-know/", + imageLink: "https://www.unite.ai/wp-content/uploads/2023/05/emily-bernal-v9vII5gV8Lw-unsplash.jpg", + credits: 5, + language: ["en"], + competency: JSON.stringify([{ + "id": 8, + "name": "Floor Inspection", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 1, + "name": "Floor Planning and Mapping", + "levels": [ + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + } + ]), + author: "Lindsay Marsh", + startDate: new Date("2023-05-01").toISOString(), + endDate: new Date("2024-09-01").toISOString(), + verificationStatus: CourseVerificationStatus.ACCEPTED, + }, { + courseId: "123e4567-e89b-42d3-a456-556642440052", + providerId: provider1.id, + title: "Strategic Coverage and Survey Techniques", + description: "Learn to plan effective surveillance coverage and conduct comprehensive site surveys.", + courseLink: "https://www.udemy.com/course/python-for-data-science-and-machine-learning-bootcamp/", + imageLink: "https://blog.imarticus.org/wp-content/uploads/2021/12/learn-Python-for-data-science.jpg", + credits: 2, + language: ["en"], + competency: JSON.stringify([{ + "id": 2, + "name": "Coverage and surveillance", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 7, + "name": "Survey", + "levels": null + } + + ]), + author: "Jose Portilla", + }, { + courseId: "123e4567-e89b-42d3-a456-556642440053", + providerId: provider.id, + title: "Earth Core and Land Assessment Essentials", + description: "Understand the core concepts of earth cutting and digging along with documenting land assessments.", + courseLink: "https://www.udemy.com/course/microsoft-excel-2013-from-beginner-to-advanced-and-beyond/", + imageLink: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Microsoft_Excel_2013-2019_logo.svg/587px-Microsoft_Excel_2013-2019_logo.svg.png", + credits: 4, + language: ["en"], + competency: JSON.stringify([{ + "id": 3, + "name": "Earth core concepts", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 4, + "name": "Assessment Documentations", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }]), + author: "Kyle Pew", + startDate: new Date("2024-05-01").toISOString(), + }, { + courseId: "123e4567-e89b-42d3-a456-556642440054", + providerId: provider1.id, + title: "Comprehensive Spatial and Interior Design", + description: "Gain holistic spatial insight and learn to design visually pleasing and functional interior spaces.", + courseLink: "https://www.udemy.com/course/devops-with-docker-kubernetes-and-azure-devops/", + imageLink: "https://img-c.udemycdn.com/course/240x135/5030480_b416_2.jpg", + credits: 120, + language: ["english", "hindi"], + competency: JSON.stringify([{ + "id": 19, + "name": "Comprehensive Spatial Insight", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 23, + "name": "Interior Engineering", + "levels": [ + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + } + ]), + author: "Jason Frig", + startDate: new Date("2023-06-01"), + endDate: new Date("2024-08-01"), + avgRating: 3.9, + verificationStatus: CourseVerificationStatus.ACCEPTED, + cqfScore: 10, + }, { + courseId: "123e4567-e89b-42d3-a456-556642440055", + providerId: provider1.id, + title: "Integrated Survey and Documentation Mastery", + description: "Develop expertise in site surveying and documenting assessments for land evaluation.", + courseLink: "https://udemy.com/courses/jQKsLpm", + imageLink: "https://udemy.com/courses/jQKsLpm/images/cover2.jpg", + credits: 160, + language: ["english", "hindi"], + avgRating: 3.5, + competency: JSON.stringify([{ + "id": 4, + "name": "Assessment Documentations", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 2, + "name": "Coverage and surveillance", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 7, + "name": "Survey", + "levels": null + }]), + author: "James Franco", + verificationStatus: CourseVerificationStatus.PENDING, + }, + { + courseId: "999e4567-e89b-42d3-a456-556642440055", + providerId: provider1.id, + title: "Architecture", + description: "This course covers all the fundamentals needed for building aesthetic architectures", + courseLink: "https://udemy.com/courses/jQKsLpm", + imageLink: "https://udemy.com/courses/jQKsLpm/images/cover2.jpg", + credits: 160, + language: ["english", "hindi"], + avgRating: 3.5, + competency: JSON.stringify([{ + "id": 1, + "name": "Floor Planning and Mapping", + "levels": [ + { + "id": 2, + "levelNumber": 2, + "name": "Level 2" + }, { + "id": 3, + "levelNumber": 3, + "name": "Level 3" + } + ] + }, { + "id": 3, + "name": "Earth core concepts", + "levels": [ + { + "id": 1, + "levelNumber": 1, + "name": "Level 1" + }, { + "id": 2, + "levelNumber": 2, + "name": "Level 2" + }, { + "id": 3, + "levelNumber": 3, + "name": "Level 3" + } + ] + }]), + author: "James Franco", + verificationStatus: CourseVerificationStatus.ACCEPTED, + }, + { + courseId: "123e4567-e89b-42d3-a456-556642440056", + providerId: provider1.id, + title: "Holistic Site and Interior Planning", + description: "Master site surveys, floor planning, and interior engineering to create functional and visually appealing spaces.", + courseLink: "https://udemy.com/courses/jQKsLpm", + imageLink: "https://udemy.com/courses/jQKsLpm/images/cover2.jpg", + credits: 160, + language: ["english", "hindi"], + competency: JSON.stringify([{ + "id": 7, + "name": "Survey", + "levels": null + }, { + "id": 1, + "name": "Floor Planning and Mapping", + "levels": [ + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 23, + "name": "Interior Engineering", + "levels": [ + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }]), + author: "Ramakrishna Upadrasta", + startDate: new Date("2023-10-10"), + endDate: new Date("2023-11-10"), + verificationStatus: CourseVerificationStatus.REJECTED, + rejectionReason: "Level associated with LLVM is wrong" + }, { + courseId: "123e4567-e89b-42d3-a456-556642440057", + providerId: provider1.id, + title: "Complete Earth Core and Spatial Engineering", + description: "Learn the fundamentals of earth core concepts, floor inspection, and achieve absolute clarity in spatial and structural planning.", + courseLink: "https://udemy.com/courses/jQKsLpm", + imageLink: "https://udemy.com/courses/jQKsLpm/images/cover2.jpg", + credits: 160, + language: ["english", "hindi"], + competency: JSON.stringify([{ + "id": 3, + "name": "Earth core concepts", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 8, + "name": "Floor Inspection", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }, { + "id": 19, + "name": "Comprehensive Spatial Insight", + "levels": [ + { + "levelNumber": 1, + "name": "Level 1", + "id": 1 + }, + { + "levelNumber": 2, + "name": "Level 2", + "id": 2 + }, + { + "levelNumber": 3, + "name": "Level 3", + "id": 3 + } + ] + }]), + author: "Ramakrishna Upadrasta", + startDate: new Date("2023-10-10"), + endDate: new Date("2023-11-10"), + rejectionReason: "Level associated with LLVM is wrong", + status: CourseStatus.ARCHIVED + }] + }) + + const admin = await prisma.admin.create({ + data: { + name: "dilu", + email: "dilu@yopmail.com", + password: hashedPassword, + image: "https://avatars.githubusercontent.com/u/46641520?v=4", + id: "87fd80a9-63e9-4e90-81bb-4b6956c2561b", + } + }); + + const admin1 = await prisma.admin.create({ + data: { + name: 'favas', + email: "favas@yopmail.com", + image: "https://avatars.githubusercontent.com/u/46641520?v=4", + password: hashedPassword1, + id: "890f2839-866f-4524-9eac-bebe0d35d607", + }, + }); + + // const resp = await prisma.course.findMany({}); + // console.log("All courses: ", resp); + const response3 = await prisma.userCourse.createMany({ + data: [{ + userId: "9f4611d4-ab92-4acd-b3ce-13594e362eca", + feedback: "Great course", + rating: 4, + status: CourseProgressStatus.COMPLETED, + courseCompletionScore: 100, + courseId: "123e4567-e89b-42d3-a456-556642440050" + }, { + userId: "9f4611d4-ab92-4acd-b3ce-13594e362eca", + courseId: "123e4567-e89b-42d3-a456-556642440051", + status: CourseProgressStatus.COMPLETED, + }, { + userId: "836ba369-fc24-4464-95ec-505d61b67ef0", + feedback: "Instructor is very friendly", + rating: 4, + status: CourseProgressStatus.COMPLETED, + courseCompletionScore: 100, + courseId: "123e4567-e89b-42d3-a456-556642440050" + }, { + userId: "836ba369-fc24-4464-95ec-505d61b67ef0", + courseId: "123e4567-e89b-42d3-a456-556642440051" + }, { + userId: "836ba369-fc24-4464-95ec-505d61b67ef0", + feedback: "Some more real world applications could be discussed", + rating: 3, + status: CourseProgressStatus.COMPLETED, + courseCompletionScore: 100, + courseId: "123e4567-e89b-42d3-a456-556642440052" + }, { + userId: "c8a43816-5a1b-4e29-9e1f-e8ef22efc669", + feedback: "Not satisfied with the content", + rating: 2, + status: CourseProgressStatus.COMPLETED, + courseCompletionScore: 100, + courseId: "123e4567-e89b-42d3-a456-556642440052" + }] + }) + console.log({ response1, response3, admin, admin1, provider, provider1, provider2, provider3 }); + +} + +async function createViews() { + let logger = new Logger("CreatingViews"); + logger.log(`Started creating views`); + + for (const sql of createViewQueries) { + logger.log(sql); + await prisma.$executeRaw`${Prisma.raw(sql)}`; + } + + const res:any = await prisma.$queryRaw`${Prisma.raw(`SELECT datname FROM pg_database WHERE datname = '${telemetryDbName}'`)}`; + if (res.length === 0) { + // Create the telemetry-views database if it does not exist + await prisma.$queryRaw`${Prisma.raw(`CREATE DATABASE "${telemetryDbName}"`)}`; + logger.log(`Database "${telemetryDbName}" created.`); + } else { + logger.log(`Database "${telemetryDbName}" already exists.`); + } + + logger.log(`Successfully created views`); +} + +async function moveViews() { + let logger = new Logger("MovingViews"); + + const telemetryClient = new Client({ + user: process.env.DATABASE_USERNAME, + host: '172.17.0.1', + database: process.env.TELEMETRY_DATABASE_NAME, + password: process.env.DATABASE_PASSWORD, + port: 5432, + }); + + await telemetryClient.connect(); + + logger.log(copyViewQueries); + + logger.log(`Started moving views`); + + for (const sql of copyViewQueries) { + logger.log(sql); + await telemetryClient.query(sql); + } + + await telemetryClient.end(); + + logger.log(`Successfully moved views`); + } + + +// execute the functions +async function main() { + try { + await seed(); + await createViews(); + await moveViews(); + } catch (e) { + console.error(e); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/src/admin/admin.controller.spec.ts b/src/admin/admin.controller.spec.ts new file mode 100644 index 0000000..70587e0 --- /dev/null +++ b/src/admin/admin.controller.spec.ts @@ -0,0 +1,27 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { PrismaModule } from 'nestjs-prisma'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { ProviderProfileResponse } from '../provider/dto/provider-profile-response.dto'; + +describe('AdminController', () => { + let controller: AdminController; + let adminService: AdminService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [PrismaModule], + controllers: [AdminController], + providers: [AdminService, PrismaService, ConfigService] + }).compile(); + + controller = module.get(AdminController); + adminService = module.get(AdminService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts new file mode 100644 index 0000000..bb09905 --- /dev/null +++ b/src/admin/admin.controller.ts @@ -0,0 +1,517 @@ +import { Controller, Body, Get, Post, Patch, Res, Delete, HttpStatus, Param, Logger, ParseUUIDPipe, UseInterceptors, UploadedFile} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { AdminService } from './admin.service'; +import { ProviderProfileResponse } from '../provider/dto/provider-profile-response.dto'; +import { getPrismaErrorStatusAndMessage } from '../utils/utils'; +import { EditProvider } from './dto/edit-provider.dto'; +import { TransactionResponse } from './dto/transaction-response.dto'; +import { Response } from 'express'; +import { CreditRequest } from './dto/credit-request.dto'; +import { json } from 'stream/consumers'; +import { ProviderSettlementDto } from './dto/provider-settlement.dto'; +import { CourseVerify } from 'src/course/dto/verify-course.dto'; +import { ProviderVerify } from './dto/provider-verify-response.dto'; +import { RejectProviderResponseDto } from './dto/reject-provider-response.dto'; +import { RejectProviderRequestDto } from './dto/reject-provider-request.dto'; +import { AdminCourseResponse } from 'src/course/dto/course-response.dto'; +import { AdminSignupDto } from './dto/signup.dto'; +import { AdminLoginDto, AdminLoginResponseDto } from './dto/login.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@Controller('admin') +@ApiTags('admin') +export class AdminController { + private readonly logger = new Logger(AdminController.name); + + constructor(private adminService: AdminService) {} + + @ApiOperation({ summary: 'signup for admin' }) + @ApiResponse({ status: HttpStatus.OK, type: AdminLoginResponseDto }) + @Post("/signup") + @UseInterceptors(FileInterceptor('image')) + // admin signup + async adminSignup( + @Body() signupDto: AdminSignupDto, + @UploadedFile() image: Express.Multer.File, + @Res() res + ) { + try { + this.logger.log(`Signing up as admin`) + + const admin = await this.adminService.signup(signupDto, image); + + this.logger.log(`Successfully signed up as admin`) + + res.status(HttpStatus.OK).json({ + message: "sign up successful", + data: { + admin: admin.id, + name: admin.name, + image: admin.image + } + }); + } catch (err) { + this.logger.error(`Failed to sign up the admin with the given credentials: `, err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to sign up as admin", + }); + } + } + + @ApiOperation({ summary: 'Login for admin' }) + @ApiResponse({ status: HttpStatus.OK, type: AdminLoginResponseDto }) + @Post("/login") + // admin login + async login( + @Body() loginDto: AdminLoginDto, + @Res() res + ) { + try { + this.logger.log(`Logging in as admin`) + + const admin = await this.adminService.login(loginDto); + + this.logger.log(`Successfully logged in as admin`) + + res.status(HttpStatus.OK).json({ + message: "login successful", + data: { + admin: admin.id, + name: admin.name, + image: admin.image + } + }); + } catch (err) { + this.logger.error(`Failed to login the admin with the given credentials: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to login as admin", + }); + } + } + + @ApiOperation({ summary: "Get all providers" }) + @ApiResponse({ status: HttpStatus.OK, type: ProviderProfileResponse, isArray: true}) + @Get('/providers') + async getAllProviders(@Res() res : Response) { + try { + this.logger.log(`Getting information of all the providers`); + + const providers = await this.adminService.findAllProviders(); + + this.logger.log(`Successfully retrieved all the providers`); + + res.status(HttpStatus.OK).json({ + message: "All providers fetched", + data: providers + }); + } catch (err) { + this.logger.error(`Failed to retreive all the providers' information: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch all the providers' information", + }); + } + } + + @ApiOperation({ summary: "Get all providers for settlement" }) + @ApiResponse({ status: HttpStatus.OK, type: ProviderSettlementDto, isArray: true}) + @Get('/:adminId/providers/settlements') + async getAllProvidersForSettlement( + @Param("adminId", ParseUUIDPipe) adminId: string, + @Res() res : Response + ) { + try { + this.logger.log(`Getting information of all the providers for settlement`); + + const providers = await this.adminService.getAllProviderInfoForSettlement(adminId); + + this.logger.log(`Successfully retrieved all the provider info for making settlement`); + + res.status(HttpStatus.OK).json({ + message: "All providers fetched", + data: providers + }); + } catch (err) { + this.logger.error(`Failed to retreive all the providers' information for settlement: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch all the providers' information for settlement", + }); + } + } + + @ApiOperation({ summary: "Settle credits for a provider" }) + @ApiResponse({ status: HttpStatus.OK, type: json}) + @Post('/:adminId/providers/settlements') + async settleProvider(@Param("adminId", ParseUUIDPipe) adminId: string, @Body() settleDto: ProviderSettlementDto, @Res() res : Response) { + try { + this.logger.log(`Settling the credits for the given provider`); + + await this.adminService.settleCredits(adminId, settleDto.id); + + this.logger.log(`Successfully settled the credits for the provider`); + + res.status(HttpStatus.OK).json({ + message: "Settlement done for the provider", + }); + } catch (err) { + this.logger.error(`Failed to retreive all the providers' information for settlement: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch all the providers' information for settlement", + }); + } + } + + @ApiOperation({ summary: "View provider profile information"}) + @ApiResponse({ status: HttpStatus.OK, type: ProviderProfileResponse}) + @Get('/providers/:providerId') + async getProviderProfile ( + @Param("providerId", ParseUUIDPipe) providerId: string, @Res() res: Response + ) { + try { + this.logger.log(`Getting provider information for id ${providerId}`); + + const provider = await this.adminService.findProviderById(providerId); + + this.logger.log(`Successfully retrieved the provider profile information`); + + res.status(HttpStatus.OK).json({ + message: "Provider profile retrieved successfully", + data: provider + }); + } catch (err) { + this.logger.error(`Failed to retrieve the provider profile information: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to retrieve the profile information for the given providerId", + }); + } + } + + + @ApiOperation({ summary: "Edit provider profile information"}) + @ApiResponse({ status: HttpStatus.OK, type: ProviderProfileResponse}) + @Patch('/providers/:providerId') + async editProviderProfile ( + @Param("providerId", ParseUUIDPipe) providerId: string, @Body() providerDto: EditProvider ,@Res() res + ){ + try { + this.logger.log(`Getting provider information for id ${providerId}`); + + const updatedProfile = await this.adminService.editProviderProfile(providerDto); + + this.logger.log(`Successfully retrieved the provider profile information`); + + res.status(HttpStatus.OK).json({ + message: "Provider profile retrieved successfully", + data: updatedProfile + }); + } catch (err) { + this.logger.error(`Failed to retrieve the provider profile information: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to retrieve the profile information for the given providerId", + }); + } + } + + @ApiOperation({ summary: "Verify provider" }) + @ApiResponse({ status: HttpStatus.OK, type: ProviderVerify }) + @Patch('/providers/:providerId/verify') + async verifyProvider(@Param("providerId", ParseUUIDPipe) providerId: string, @Res() res) { + try { + this.logger.log(`Verifying the provider's account with id ${providerId}`); + + const response = await this.adminService.verifyProvider(providerId); + + this.logger.log(`Successfully verified the provider account`); + + res.status(HttpStatus.OK).json({ + message: "Verified the provider", + data: response.id + }); + } catch (err) { + this.logger.error(`Failed to verify the provider with id ${providerId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to verify the provider", + }); + } + } + + @ApiOperation({ summary: "Reject provider" }) + @ApiResponse({ status: HttpStatus.OK, type: RejectProviderResponseDto }) + @Patch('/providers/:providerId/reject') + async rejectProvider(@Param("providerId", ParseUUIDPipe) providerId: string, @Body() rejectProviderDto: RejectProviderRequestDto, @Res() res) { + try { + this.logger.log(`Rejecting the provider's account with id ${providerId}`); + + const response = await this.adminService.rejectProvider(providerId, rejectProviderDto.rejectionReason); + + this.logger.log(`Successfully rejected the provider account`); + + res.status(HttpStatus.OK).json({ + message: "Rejected the provider", + data: { + providerId: response.id, + rejectionReason: response.rejectionReason + } + }); + } catch (err) { + this.logger.error(`Failed to reject the provider account with id ${providerId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to reject the provider account", + }); + } + } + + @ApiOperation({ summary: "Get all the courses"}) + @ApiResponse({ status: HttpStatus.OK, type: AdminCourseResponse, isArray: true}) + @Get('/courses/') + async getAllCourses(@Res() res){ + try { + this.logger.log(`Fetching all courses on marketplace (verified, pending & rejected)`); + + const courses = await this.adminService.findAllCourses(); + + this.logger.log(`Successfully retrieved all the courses`); + + res.status(HttpStatus.OK).json({ + message: "All courses retrieved successfully", + data: courses + }); + } catch (err) { + this.logger.error(`Failed to retrieve all courses: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to retrieve all courses", + }); + } + } + + @ApiOperation({ summary: "Get a course, given its courseId"}) + @ApiResponse({ status: HttpStatus.OK, type: AdminCourseResponse}) + @Get('/courses/:courseId') + async getCourseById ( + @Param("courseId", ParseUUIDPipe) courseId: string, @Res() res + ){ + try { + this.logger.log(`Getting course information for id ${courseId}`); + + const course = await this.adminService.findCourseById(courseId); + + this.logger.log(`Successfully retrieved the course`); + + res.status(HttpStatus.OK).json({ + message: "Course retrieved successfully", + data: course + }); + } catch (err) { + this.logger.error(`Failed to retrieve the course for the courseId ${courseId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to retrieve the course with id ${courseId}`, + }); + } + } + + + @ApiOperation({ summary: "Accept course and assign a cqf_score"}) + @ApiResponse({ status: HttpStatus.OK, type: AdminCourseResponse}) + @Patch('/courses/:courseId/accept') + async acceptCourse ( + @Param("courseId", ParseUUIDPipe) courseId: string, @Body() verifyBody: CourseVerify, @Res() res + ) { + try { + this.logger.log(`Verifying the course with id ${courseId}`); + + const course = await this.adminService.acceptCourse(courseId, verifyBody.cqf_score); + + this.logger.log(`Successfully accepted the course`); + + res.status(HttpStatus.OK).json({ + message: "Course accepted successfully", + data: course + }); + } catch (err) { + this.logger.error(`Failed to accept the course for the courseId ${courseId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to accept the course with id ${courseId}`, + }); + } + } + + @ApiOperation({ summary: "Reject a course given its courseId"}) + @ApiResponse({ status: HttpStatus.OK, type: AdminCourseResponse}) + @Patch('/courses/:courseId/reject') + async rejectCourse ( + @Param("courseId", ParseUUIDPipe) courseId: string, @Body() courseRejectionRequestDto: RejectProviderRequestDto, @Res() res + ) { + try { + this.logger.log(`Processing reject request of course with id ${courseId}`); + + const course = await this.adminService.rejectCourse(courseId, courseRejectionRequestDto.rejectionReason); + + this.logger.log(`Successfully rejected the course`); + + res.status(HttpStatus.OK).json({ + message: "Course rejected successfully", + data: course + }); + } catch (err) { + this.logger.error(`Failed to reject the course with the courseId ${courseId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to reject the course with id ${courseId}`, + }); + } + } + + @ApiOperation({ summary: "Remove a course given its courseId"}) + @ApiResponse({ status: HttpStatus.OK, type: AdminCourseResponse}) + @Delete('/courses/:courseId') + async removeCourse ( + @Param("courseId", ParseUUIDPipe) courseId: string, @Res() res + ) { + try { + this.logger.log(`Processing removal request of course with id ${courseId}`); + + const course = await this.adminService.removeCourse(courseId); + + this.logger.log(`Successfully deleted the course`); + + res.status(HttpStatus.OK).json({ + message: "Course deleted successfully", + data: course + }); + } catch (err) { + this.logger.error(`Failed to delete the course with the courseId ${courseId}: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to delete the course with id ${courseId}`, + }); + } + } + + + + @ApiOperation({ summary: "Get transaction history between admin and consumers"}) + @ApiResponse({ status: HttpStatus.OK, type: TransactionResponse, isArray: true}) + @Get('/:adminId/transactions/consumers') + async getTransactions (@Param("adminId", ParseUUIDPipe) adminId: string, @Res() res + ){ + try { + this.logger.log(`Getting all transactions between admin and consumers.`); + + const transactions = await this.adminService.getTransactions(adminId); + this.logger.log(`Successfully fetched all the transactions between admin and consumers`); + + res.status(HttpStatus.OK).json({ + message: "Fetched admin-consumers transactions", + data: transactions + }); + } catch (err) { + this.logger.error(`Failed to fetch the transactions between admin and consumers: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to fetch the transactions between admin and consumers`, + }); + } + } + + @ApiOperation({ summary: "Add credits to a Provider"}) + @ApiResponse({ status: HttpStatus.OK, type: json}) + @Post('/:adminId/providers/credits/addCredits') + async addCredits (@Param("adminId", ParseUUIDPipe) adminId: string, @Body() reqBody: CreditRequest, @Res() res + ){ + try { + this.logger.log(`Adding credits to providers' wallet`); + + const providerId = reqBody.providerId; + const credits = reqBody.credits; + + const provider = await this.adminService.addOrRemoveCreditsToProvider(adminId, providerId, credits); + + this.logger.log(`Succesfully Added credits to providers' wallet.`); + + res.status(HttpStatus.OK).json({ + message: "Added credits to provider", + data: provider + }); + } catch (err) { + this.logger.error(`Failed to add credits to the provider: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to add credits to the provider`, + }); + } + } + + @ApiOperation({ summary: "Remove credits from a Provider"}) + @ApiResponse({ status: HttpStatus.OK, type: json }) + @Post('/:adminId/providers/credits/reduceCredits') + async reduceCredits (@Param("adminId") adminId: string, @Body() reqBody: CreditRequest, @Res() res + ){ + try { + this.logger.log(`Reducing credits from providers' wallet`); + + const providerId = reqBody.providerId; + const credits = -reqBody.credits; + + const provider = await this.adminService.addOrRemoveCreditsToProvider(adminId, providerId, credits); + + this.logger.log(`Succesfully reduced credits from providers' wallet.`); + + res.status(HttpStatus.OK).json({ + message: "Removed credits from provider", + data: provider + }); + } catch (err) { + this.logger.error(`Failed to remove credits from the provider: `,err.message); + + const { errorMessage, statusCode } = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || `Failed to remove credits from the provider`, + }); + } + + } +} diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts new file mode 100644 index 0000000..353dca0 --- /dev/null +++ b/src/admin/admin.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PrismaModule } from 'nestjs-prisma'; +import { MockWalletModule } from 'src/mock-wallet/mock-wallet.module'; +import { CourseModule } from 'src/course/course.module'; +import { ProviderModule } from 'src/provider/provider.module'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [PrismaModule, CourseModule, MockWalletModule, ProviderModule, AuthModule], + controllers: [AdminController], + providers: [AdminService, PrismaService], + exports: [AdminService] +}) +export class AdminModule {} diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..b3f1aae --- /dev/null +++ b/src/admin/admin.service.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminService } from './admin.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; + +describe('AdminService', () => { + let service: AdminService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AdminService, PrismaService, ConfigService], + }).compile(); + + service = module.get(AdminService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts new file mode 100644 index 0000000..fbd99cc --- /dev/null +++ b/src/admin/admin.service.ts @@ -0,0 +1,285 @@ + +import { BadRequestException, HttpException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Provider, Course } from '@prisma/client'; +import { EditProvider } from './dto/edit-provider.dto'; +import { MockWalletService } from '../mock-wallet/mock-wallet.service'; +import { ProviderService } from 'src/provider/provider.service'; +import { CourseService } from 'src/course/course.service'; +import axios from 'axios'; +import { AdminCourseResponse } from 'src/course/dto/course-response.dto'; +import { ProviderSettlementDto } from './dto/provider-settlement.dto'; +import { ProviderProfileResponse } from 'src/provider/dto/provider-profile-response.dto'; +import { AdminSignupDto } from './dto/signup.dto'; +import { uploadFile } from 'src/utils/minio'; +import { AuthService } from 'src/auth/auth.service'; +import { AdminLoginDto } from './dto/login.dto'; + +@Injectable() +export class AdminService { + + constructor( + private prisma: PrismaService, + private providerService: ProviderService, + private courseService: CourseService, + private authService: AuthService + ) {} + + // create a new admin + async signup(signupDto: AdminSignupDto, image?: Express.Multer.File) { + + let imageUrl: string | undefined; + if(image) { + const imageName = signupDto.name.replaceAll(" ", "_") + imageUrl = await uploadFile(`admin/${imageName}`, image.buffer) + } + + // check if there is a user with admin role in the user service + const baseUrl = process.env.USER_SERVICE_URL || ""; + const headers = { + 'Authorization': 'bearer ' + process.env.USER_SERVICE_TOKEN, + 'Content-Type': 'application/json', + 'Cookie': process.env.USER_SERVICE_COOKIE + }; + + const data = { + "request": { + "filters": { + "profileDetails.personalDetails.primaryEmail": signupDto.email + } + } + }; + + let response = await axios.post(baseUrl, data, {headers}); + console.log("Response from user service: ", JSON.stringify(response.data.result?.response?.content[0]?.organisations[0])); + if(response.data.result?.response?.content[0]?.organisations[0]?.roles?.filter(role => role == "ADMIN").length != 0 ) { + // Hashing the password + const hashedPassword = await this.authService.hashPassword(signupDto.password); + + const admin = await this.prisma.admin.create({ + data: { + id: response.data.result?.response?.content[0].userId, + name: signupDto.name, + email: signupDto.email, + password: hashedPassword, + image: imageUrl + } + }); + try { + // Forward to wallet service for creation of wallet + if(!process.env.WALLET_SERVICE_URL) + throw new HttpException("Wallet service URL not defined", 500); + + const url = process.env.WALLET_SERVICE_URL; + const endpoint = url + `/api/wallet/create`; + const reqBody = { + userId: admin.id, + type: 'ADMIN', + credits: 0 + } + await axios.post(endpoint, reqBody); + } catch(err) { + await this.prisma.admin.delete({ + where: { + id: admin.id + } + }); + throw new HttpException(err.response || "Wallet service not running", err.response?.status || err.status || 500); + } + try { + // Forward to marketplace portal for creation in marketplace + if(!process.env.MARKETPLACE_PORTAL_URL) + throw new HttpException("Marketplace service URL not defined", 500); + + const url = process.env.MARKETPLACE_PORTAL_URL; + const endpoint = url + `/api/admin/${admin.id}`; + + await axios.post(endpoint); + } catch(err) { + await this.prisma.admin.delete({ + where: { + id: admin.id + } + }); + throw new HttpException(err.response || "Marketplace service not running", err.response?.status || err.status || 500); + } + return admin; + + } else { + throw new HttpException("User with the given email Id not present as an ADMIN in User Service", 400); + } + + } + + async login(loginDto: AdminLoginDto) { + let admin = await this.prisma.admin.findUnique({ + where: { email: loginDto.email } + }); + + if (admin == null) { + throw new NotFoundException(`Admin not found`); + } + const isMatch = await this.authService.comparePasswords(loginDto.password, admin.password); + if (!isMatch) { + throw new BadRequestException(`Invalid credentials`); + } + return admin; + } + + // verify provider account + async verifyProvider(providerId: string) { + + return this.providerService.verifyProvider(providerId); + } + + // reject provider account + async rejectProvider(providerId: string, rejectionReason: string) { + return this.providerService.rejectProvider(providerId, rejectionReason); + } + + // fetch all providers on marketplace + async findAllProviders(): Promise[]> { + + return this.providerService.fetchAllProviders(); + } + + // fetch provider with the given id + async findProviderById(providerId: string): Promise { + + return this.providerService.getProvider(providerId); + } + + // fetch all courses added onto the marketplace + async findAllCourses() : Promise { + + return this.courseService.fetchAllCourses(); + } + + // find course by Id + async findCourseById(courseId: string): Promise { + + return this.courseService.getCourse(courseId); + } + + // accept a course along with the cqf score + async acceptCourse(courseId: string, cqf_score?: number) { + + return this.courseService.acceptCourse(courseId, cqf_score); + } + + // reject a course + async rejectCourse(courseId: string, rejectionReason: string) { + + return this.courseService.rejectCourse(courseId, rejectionReason); + } + + // remove a course from marketplace + async removeCourse(courseId: string) { + + return this.courseService.removeCourse(courseId); + } + + // get all admin-consumer transactions + async getTransactions(adminId: string) { + + if(!process.env.WALLET_SERVICE_URL) + throw new HttpException("Wallet service URL not defined", 500); + const walletService = process.env.WALLET_SERVICE_URL; + const endpoint = `/admin/${adminId}/transactions/consumers`; + const url = walletService + endpoint; + + const response = await axios.get(url); + return response.data; + } + + // Add or remove credits to provider wallet + async addOrRemoveCreditsToProvider(adminId: string, providerId: string, credits: number) { + const walletService = process.env.WALLET_SERVICE_URL; + if(!walletService) + throw new HttpException("Wallet service URL not defined", 500); + let endpoint: string; + if(credits >= 0) { + endpoint = `/admin/${adminId}/add-credits`; + } else { + endpoint = `/admin/${adminId}/reduce-credits`; + } + const url = walletService + endpoint; + const requestBody = { + consumerId: providerId, + credits: credits + }; + let response = await axios.post(url, requestBody); + return response; + + } + + // edit provider profile information + async editProviderProfile(profileInfo: EditProvider) { + + return this.providerService.editProviderProfileByAdmin(profileInfo); + } + + // Get number of course purchases for a provider + async getNoOfCoursePurchasesForProvider(providerId: string) { + return await this.prisma.userCourse.count({ + where: { + course: { + providerId: providerId + } + } + }); + } + + // Get the number of courses added by a provider + async getNumberOfCoursesForProvider(providerId: string) { + return await this.prisma.course.count({ + where: { + providerId: providerId + } + }); + } + + // Get the number of credits in a provider wallet + async getAllProvidersWalletCredits(adminId: string) { + + const url = process.env.WALLET_SERVICE_URL; + if(!url) + throw new HttpException("Wallet service URL not defined", 500); + const endpoint = url + `/api/admin/${adminId}/credits/providers`; + const resp = await axios.get(endpoint); + const creditsResponse = resp.data.data.credits; + const creditsMap = {}; + creditsResponse.forEach((c) => { + creditsMap[c.providerId] = c.credits; + }); + return creditsMap; + } + + // Get all the providers information for settlement + async getAllProviderInfoForSettlement(adminId: string): Promise { + + const providers = await this.providerService.fetchProvidersForSettlement(); + const creditsMap = await this.getAllProvidersWalletCredits(adminId); + + return providers.map((p) => { + return { + ...p, + totalCredits: creditsMap[p.id] + } + }); + } + + // settle credits for a provider + async settleCredits(adminId: string, providerId: string) { + // Need to add transaction, add paymentReceipt additional settlement processing + + const url = process.env.WALLET_SERVICE_URL; + if(!url) + throw new HttpException("Wallet service URL not defined", 500); + const endpoint = url + `/api/admin/${adminId}/providers/${providerId}/settle-credits`; + + const response = await axios.post(endpoint); + } + +} + diff --git a/src/admin/dto/credit-request.dto.ts b/src/admin/dto/credit-request.dto.ts new file mode 100644 index 0000000..de19234 --- /dev/null +++ b/src/admin/dto/credit-request.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsInt, IsNotEmpty, IsNumber, IsUUID } from 'class-validator'; + +export class CreditRequest { + + @ApiProperty() + @IsNotEmpty() + @IsUUID() + readonly providerId: string + + @ApiProperty() + @IsNotEmpty() + @IsInt() + readonly credits: number +} \ No newline at end of file diff --git a/src/admin/dto/edit-provider.dto.ts b/src/admin/dto/edit-provider.dto.ts new file mode 100644 index 0000000..e39375a --- /dev/null +++ b/src/admin/dto/edit-provider.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsEnum, IsObject, IsOptional, IsPhoneNumber, IsString, IsUUID, IsUrl } from "class-validator"; +import { ProviderStatus } from "@prisma/client"; +import { PaymentInfo } from "src/utils/types"; + + +export class EditProvider { + @ApiProperty({required: false}) + @IsUUID() + @IsOptional() + id?: string; + + @ApiProperty() + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({format: 'email'}) + @IsEmail() + @IsOptional() + email?: string; + + // organisation name + @ApiProperty() + @IsOptional() + @IsString() + orgName?: string; + + // organisation logo image link + @ApiProperty() + @IsOptional() + @IsUrl() + orgLogo?: string; + + // phone number + @ApiProperty() + @IsOptional() + @IsPhoneNumber() + phone?: string; + + @ApiProperty() + @IsOptional() + @IsObject() + paymentInfo?: PaymentInfo; + + @ApiProperty() + @IsOptional() + @IsEnum(ProviderStatus) + status?: ProviderStatus; + + @ApiProperty() + @IsOptional() + @IsString() + rejectionReason?: string | null; + // readonly courses: Course[]; +} \ No newline at end of file diff --git a/src/admin/dto/login.dto.ts b/src/admin/dto/login.dto.ts new file mode 100644 index 0000000..93a1b59 --- /dev/null +++ b/src/admin/dto/login.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsString } from "class-validator"; + +export class AdminLoginDto { + + // email ID + @ApiProperty() + @IsNotEmpty() + @IsEmail() + email: string + + // password + @ApiProperty() + @IsNotEmpty() + @IsString() + password: string +} + +export class AdminLoginResponseDto { + + // provider ID + readonly adminId: number + readonly name: string + readonly image: string +} \ No newline at end of file diff --git a/src/admin/dto/provider-settlement.dto.ts b/src/admin/dto/provider-settlement.dto.ts new file mode 100644 index 0000000..a44431e --- /dev/null +++ b/src/admin/dto/provider-settlement.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNumber, IsOptional, IsString, IsUUID, IsUrl } from 'class-validator'; + + +export class ProviderSettlementDto { + @ApiProperty() + @IsUUID() + id: string; + + @ApiProperty() + @IsOptional() + @IsUrl() + imageLink?: string; + + @ApiProperty() + @IsOptional() + @IsString() + name?: string; + + @ApiProperty() + @IsNumber() + @IsOptional() + totalCourses?: number; + + @ApiProperty() + @IsNumber() + @IsOptional() + activeUsers?: number; + + @ApiProperty() + @IsNumber() + @IsOptional() + totalCredits?: number; + +} \ No newline at end of file diff --git a/src/admin/dto/provider-verify-response.dto.ts b/src/admin/dto/provider-verify-response.dto.ts new file mode 100644 index 0000000..ae994e1 --- /dev/null +++ b/src/admin/dto/provider-verify-response.dto.ts @@ -0,0 +1,3 @@ +export class ProviderVerify { + readonly providerId: string; +} \ No newline at end of file diff --git a/src/admin/dto/reject-provider-request.dto.ts b/src/admin/dto/reject-provider-request.dto.ts new file mode 100644 index 0000000..aba3668 --- /dev/null +++ b/src/admin/dto/reject-provider-request.dto.ts @@ -0,0 +1,10 @@ +import { IsNotEmpty, IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + + +export class RejectProviderRequestDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + rejectionReason: string +} \ No newline at end of file diff --git a/src/admin/dto/reject-provider-response.dto.ts b/src/admin/dto/reject-provider-response.dto.ts new file mode 100644 index 0000000..8cfe17f --- /dev/null +++ b/src/admin/dto/reject-provider-response.dto.ts @@ -0,0 +1,4 @@ +export class RejectProviderResponseDto { + readonly providerId: string; + readonly rejectionReason: string; +} \ No newline at end of file diff --git a/src/admin/dto/signup.dto.ts b/src/admin/dto/signup.dto.ts new file mode 100644 index 0000000..4ced12c --- /dev/null +++ b/src/admin/dto/signup.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsString, IsStrongPassword } from "class-validator"; + +export class AdminSignupDto { + + // name + @ApiProperty() + @IsNotEmpty() + @IsString() + name: string + + // email ID + @ApiProperty() + @IsNotEmpty() + @IsEmail() + email: string + + // password + @ApiProperty() + // A strong password should have a minimum length of 8 characters, + // at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special symbol. + @IsNotEmpty({ message: 'Password is required' }) + @IsStrongPassword( + { + minLength: 8, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }, + { message: 'Password is not strong enough' }, + ) + password: string +} \ No newline at end of file diff --git a/src/admin/dto/transaction-response.dto.ts b/src/admin/dto/transaction-response.dto.ts new file mode 100644 index 0000000..57a40a0 --- /dev/null +++ b/src/admin/dto/transaction-response.dto.ts @@ -0,0 +1,18 @@ + + +// define in prisma.schema +enum TransactionType { + purchase, + creditRequest, + settlement +} + +export class TransactionResponse { + readonly transactionId: number; + readonly fromId: number; + readonly toId: number; + readonly credits: number; + readonly type: TransactionType; + readonly description?: string; + readonly createdAt: Date; +} \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 9046544..4cadd62 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,7 +3,11 @@ import { ConfigModule } from "@nestjs/config"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; import { PrismaModule } from "./prisma/prisma.module"; -import { PrismaService } from "./prisma/prisma.service"; +import { ProviderModule } from "./provider/provider.module"; +import { AdminModule } from './admin/admin.module'; +import { MockWalletModule } from './mock-wallet/mock-wallet.module'; +import { CourseModule } from "./course/course.module"; +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ @@ -11,8 +15,13 @@ import { PrismaService } from "./prisma/prisma.service"; isGlobal: true, }), PrismaModule, + ProviderModule, + AdminModule, + MockWalletModule, + CourseModule, + AuthModule ], controllers: [AppController], - providers: [AppService, PrismaService], + providers: [AppService], }) export class AppModule {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..eaa7852 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Module({ + providers: [AuthService], + exports: [AuthService] +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..800ab66 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..a2f3fc2 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class AuthService { + + // generate hash for a given password + async hashPassword(password: string): Promise { + const saltRounds = 10; + return bcrypt.hash(password, saltRounds); + } + + async comparePasswords(plainTextPassword: string, hashedPassword: string): Promise { + return bcrypt.compare(plainTextPassword, hashedPassword); + } +} \ No newline at end of file diff --git a/src/course/course.controller.ts b/src/course/course.controller.ts new file mode 100644 index 0000000..6d279e9 --- /dev/null +++ b/src/course/course.controller.ts @@ -0,0 +1,231 @@ +import { Body, Controller, Get, HttpStatus, Logger, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res } from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { CourseService } from "./course.service"; +import { FeedbackDto } from "./dto/feedback.dto"; +import { CourseResponse } from "./dto/course-response.dto"; +import { getPrismaErrorStatusAndMessage } from "src/utils/utils"; +import { SearchResponseDTO } from "./dto/search-response.dto"; +import { PurchaseDto, PurchaseResponseDto } from "./dto/purchase.dto"; + +@Controller('course') +@ApiTags('course') +export class CourseController { + + private readonly logger = new Logger(CourseController.name); + + constructor(private readonly courseService: CourseService) {} + + @ApiOperation({ summary: 'Search courses' }) + @ApiResponse({ status: HttpStatus.OK, type: [CourseResponse] }) + @Get("/search") + // search courses + async searchCourses( + @Query('searchInput') searchInput: string, + @Res() res + ) { + try { + this.logger.log(`Getting information of courses`); + + const courses = await this.courseService.searchCourses(searchInput); + + this.logger.log(`Successfully retrieved the courses`); + + res.status(HttpStatus.OK).json({ + message: "search successful", + data: courses + }) + } catch (err) { + this.logger.error(`Failed to retreive the courses' information: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch the courses' information", + }); + } + } + + @ApiOperation({ summary: 'Get most popular courses' }) + @ApiResponse({ status: HttpStatus.OK, type: [CourseResponse] }) + @Get("/popular") + // Get a list of the most popular courses + async mostPopularCourses( + @Query('limit') limit: number, + @Query('offset') offset: number, + @Res() res + ) { + try { + this.logger.log(`Fetching most popular courses`); + + const courses = await this.courseService.mostPopularCourses(limit, offset); + + this.logger.log(`Successfully fetched the most popular courses`); + + res.status(HttpStatus.OK).json({ + message: "fetch successful", + data: courses + }) + } catch (err) { + this.logger.error(`Failed to fetch courses: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch courses", + }); + } + } + + @ApiOperation({ summary: 'Get recommended courses' }) + @ApiResponse({ status: HttpStatus.OK, type: [CourseResponse] }) + @Get("/recommended") + // Get a list of the recommended courses + async recommendedCourses( + @Query('competencies') competencies: string[], + @Res() res + ) { + try { + this.logger.log(`Fetching recommended courses`); + + const courses = await this.courseService.recommendedCourses(competencies); + + this.logger.log(`Successfully fetched the recommended courses`); + + res.status(HttpStatus.OK).json({ + message: "fetch successful", + data: courses + }) + } catch (err) { + this.logger.error(`Failed to fetch courses: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch courses", + }); + } + } + + @ApiOperation({ summary: 'Fetch details of one course' }) + @ApiResponse({ status: HttpStatus.OK, type: CourseResponse }) + @Get("/:courseId") + // Fetch details of one course + async getCourse( + @Param("courseId", ParseUUIDPipe) courseId: string, + @Res() res + ) { + try { + this.logger.log(`Getting information of one course`); + + const course = await this.courseService.getCourseByConsumer(courseId); + + this.logger.log(`Successfully retrieved the course`); + + res.status(HttpStatus.OK).json({ + message: "fetch successful", + data: course + }) + } catch (err) { + this.logger.error(`Failed to retreive the course: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch the course", + }); + } + } + + @ApiOperation({ summary: 'Confirmation of user purchase of a course' }) + @ApiResponse({ status: HttpStatus.OK, type: PurchaseResponseDto }) + @Post("/:courseId/purchase/:consumerId") + // Confirmation of user purchase of a course + async purchaseCourse( + @Param("courseId", ParseUUIDPipe) courseId: string, + @Param("consumerId", ParseUUIDPipe) consumerId: string, + @Res() res + ) { + try { + this.logger.log(`Recording the user purchase of the course`); + + const response = await this.courseService.addPurchaseRecord(courseId, consumerId); + + this.logger.log(`Successfully recorded the purchase`); + + res.status(HttpStatus.OK).json({ + message: "purchase successful", + data: response + }) + } catch (err) { + this.logger.error(`Failed to record the purchase: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to record the purchase", + }); + } + } + + @ApiOperation({ summary: 'Give feedback and rating' }) + @ApiResponse({ status: HttpStatus.OK }) + @Patch("/:courseId/feedback/:userId") + // Give feedback and rating + async feedback( + @Param("courseId", ParseUUIDPipe) courseId: string, + @Param("userId", ParseUUIDPipe) userId: string, + @Body() feedbackDto: FeedbackDto, + @Res() res + ) { + try { + this.logger.log(`Recording the course feedback`); + + await this.courseService.giveCourseFeedback(courseId, userId, feedbackDto); + + this.logger.log(`Successfully recorded the feedback`); + + res.status(HttpStatus.OK).json({ + message: "feedback successful" + }) + } catch (err) { + this.logger.error(`Failed to record the feedback: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to record the feedback", + }); + } + } + + @ApiOperation({ summary: 'Filter for verified Courses' }) + @ApiResponse({ status: HttpStatus.OK }) + @Post("/verifyFilter") + // Filter for admin verified courses + async verifiedFilter( + @Body() courses: SearchResponseDTO[], + @Res() res + ) { + try { + this.logger.log(`Filtering for courses verified by admin`); + + const filteredCourses: SearchResponseDTO[] = await this.courseService.filterVerified(courses); + + this.logger.log(`Successfully filtered the courses`); + + res.status(HttpStatus.OK).json({ + message: "Filtering successfull", + data: filteredCourses + }); + } catch (err) { + this.logger.error(`Failed to filter the courses: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to filter the courses", + }); + } + } + +} diff --git a/src/course/course.module.ts b/src/course/course.module.ts new file mode 100644 index 0000000..643a3c1 --- /dev/null +++ b/src/course/course.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { CourseService } from "./course.service"; +import { CourseController } from "./course.controller"; + +@Module({ + controllers: [CourseController], + providers: [PrismaService, CourseService], + exports: [CourseService] +}) +export class CourseModule {} diff --git a/src/course/course.service.ts b/src/course/course.service.ts new file mode 100644 index 0000000..6e6c648 --- /dev/null +++ b/src/course/course.service.ts @@ -0,0 +1,635 @@ +import { NotFoundException, BadRequestException, Injectable, NotAcceptableException, HttpException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { FeedbackDto } from "./dto/feedback.dto"; +import { AddCourseDto } from "./dto/add-course.dto"; +import { CourseProgressStatus, CourseStatus, CourseVerificationStatus } from "@prisma/client"; +import { CompleteCourseDto } from "./dto/completion.dto"; +import { EditCourseDto } from "./dto/edit-course.dto"; +import { AdminCourseResponse, CourseResponse, ProviderCourseResponse } from "src/course/dto/course-response.dto"; +import { CourseTransactionDto } from "./dto/transaction.dto"; +import { SearchResponseDTO } from "./dto/search-response.dto"; +import { CourseStatusDto } from "./dto/course-status.dto"; +import axios from "axios"; +import { uploadFile } from "src/utils/minio"; +import { ProviderProfileResponse } from "src/provider/dto/provider-profile-response.dto"; +import { PurchaseResponseDto } from "./dto/purchase.dto"; + +@Injectable() +export class CourseService { + constructor( + private prisma: PrismaService, + ) {} + + async searchCourses(searchInput: string): Promise { + + // Searches for the courses available in the DB that match or contain the input search string + // in their title, author, description or competency + let courses = await this.prisma.course.findMany({ + where: { + OR: [{ + title: { + contains: searchInput, + mode: "insensitive", + } + }, { + author: { + contains: searchInput, + mode: "insensitive", + } + }, { + description: { + contains: searchInput, + mode: "insensitive", + } + }, { + competency: { + path: [searchInput.toLowerCase()], + not: "null", + } + }, { + competency: { + path: [searchInput], + not: "null", + } + } + ] + }, + include: { + provider: { + select: { + orgName: true, + } + }, + _count: { + select: { + userCourses: true + } + } + } + }); + // Filter out the courses that are not accepted, archived or not available + courses = courses.filter((c) => + c.verificationStatus == CourseVerificationStatus.ACCEPTED + && c.status == CourseStatus.UNARCHIVED + && (c.startDate ? c.startDate <= new Date(): true) + && (c.endDate ? c.endDate >= new Date(): true) + ); + return courses.map((c) => { + let {cqfScore, impactScore, verificationStatus, rejectionReason, provider, _count, competency, courseLink, ...clone} = c; + const courseResponse: CourseResponse = { + ...clone, + providerName: provider.orgName, + numOfUsers: _count.userCourses, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + return courseResponse; + }); + } + + async addCourse(addCourseDto: AddCourseDto, provider: ProviderProfileResponse, image: Express.Multer.File) { + + const imageName = addCourseDto.title.replaceAll(" ", "_") + const imageLink = await uploadFile( `provider/${provider.orgName.replaceAll(" ", "_")}/${imageName}`, image.buffer); + + + // add new course to the platform + return await this.prisma.course.create({ + data: { + providerId: provider.id, + ...addCourseDto, + imageLink, + } + }); + } + + async addPurchaseRecord(courseId: string, consumerId: string): Promise { + + // Validate course + const course = await this.getCourse(courseId); + + if(course.verificationStatus != CourseVerificationStatus.ACCEPTED) + throw new BadRequestException("Course is not accepted"); + if(course.status == CourseStatus.ARCHIVED) + throw new BadRequestException("Course is archived"); + if((course.startDate && course.startDate > new Date()) || (course.endDate && course.endDate < new Date())) + throw new BadRequestException("Course is not available at the moment"); + + // Check if course already purchased + const record = await this.prisma.userCourse.findFirst({ + where: { userId: consumerId, courseId: courseId } + }); + if(record != null) + throw new BadRequestException("Course already purchased by the user"); + + + // create new record for purchase + await this.prisma.userCourse.create({ + data: { + userId: consumerId, + courseId, + } + }); + return { + courseLink: course.courseLink + } + } + + async changeStatus(courseId: string, providerId: string, courseStatusDto: CourseStatusDto) { + + // Validate course + const course = await this.getCourse(courseId) + + if(course.providerId != providerId) + throw new BadRequestException("Course does not belong to the provider"); + + // update the course status to archived + return this.prisma.course.update({ + where: { courseId, providerId }, + data: { status: courseStatusDto.status } + }); + } + + async editCourse(courseId: string, editCourseDto: EditCourseDto, provider: ProviderProfileResponse, image?: Express.Multer.File) { + + // Validate course + const course = await this.getCourse(courseId); + let imgUrl = course.imageLink; + if(image) { + const imageName = (editCourseDto.title ?? course.title).replaceAll(" ", "_") + imgUrl = await uploadFile( `provider/${provider.orgName.replaceAll(" ", "_")}/${imageName}`, image.buffer); + } + + // update the course details as required and change its verification status to pending + return this.prisma.course.update({ + where: { courseId }, + data: { + ...editCourseDto, + verificationStatus: CourseVerificationStatus.PENDING, + imageLink: imgUrl + } + }); + } + + async getCourse(courseId: string): Promise { + + // Find course by ID and throw error if not found + const course = await this.prisma.course.findUnique({ + where: { + courseId + }, + include: { + provider: { + select: { + orgName: true, + orgLogo: true + } + } + } + }) + if(!course) + throw new NotFoundException("Course does not exist"); + + // let courseResponse: AdminCourseResponse + const { provider, competency, ...courseResponse } = course; + return { + ...courseResponse, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + providerName: provider.orgName, + providerLogo: provider.orgLogo + } + } + + async getNumOfCourseUsers(courseId: string) { + + return this.prisma.userCourse.count({ + where: { + courseId + } + }) + } + + async getCourseByConsumer(courseId: string): Promise { + + // Find course by ID and throw error if not found + const course = await this.getCourse(courseId); + const numOfUsers = await this.getNumOfCourseUsers(courseId); + + if(course.verificationStatus != CourseVerificationStatus.ACCEPTED) + throw new BadRequestException("Course is not accepted"); + if(course.status != CourseStatus.UNARCHIVED) + throw new BadRequestException("Course is archived"); + if((course.startDate && course.startDate > new Date()) || (course.endDate && course.endDate < new Date())) + throw new BadRequestException("Course is not available at the moment"); + + const {cqfScore, impactScore, verificationStatus, rejectionReason, courseLink, ...clone} = course; + return { + ...clone, + numOfUsers + } + } + + async giveCourseFeedback(courseId: string, userId: string, feedbackDto: FeedbackDto) { + + // Validate course + const course = await this.getCourse(courseId); + + // Find purchase record with consumer Id and course ID and throw error if not found + // Or if course not complete + const userCourse = await this.prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId, + courseId: courseId + } + }, + }); + if(!userCourse) + throw new NotFoundException("This user has not subscribed to this course"); + + if(userCourse.status != CourseProgressStatus.COMPLETED) + throw new BadRequestException("Course not complete"); + + // Add feedback + await this.prisma.userCourse.update({ + where: { + userId_courseId: { + courseId, + userId + } + }, + data: { + ...feedbackDto + } + }); + + // Change average rating of the course + const avgRating = await this.prisma.userCourse.aggregate({ + where: { + courseId + }, + _avg: { + rating: true + } + }); + await this.prisma.course.update({ + where: { + courseId + }, + data: { + avgRating: avgRating._avg.rating + } + }); + } + + async deleteCourse(courseId: string) { + + // Delete the course entry from db + await this.prisma.course.delete({ + where: { + courseId + } + }) + } + + async getProviderCourses(providerId: string): Promise { + + // Get all courses added by a single provider + const courses = await this.prisma.course.findMany({ + where: { + providerId + } + }) + return courses.map((c) => { + const {cqfScore, impactScore, competency, ...clone} = c; + return { + ...clone, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + }) + } + + async getPurchasedUsersByCourseId(courseId: string) { + + // Get all users that have bought a course + return this.prisma.userCourse.findMany({ + where: { + courseId + }, + }) + } + + async markCourseComplete(completeCourseDto: CompleteCourseDto) { + + // Validate course + const userCourse = await this.prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: completeCourseDto.userId, + courseId: completeCourseDto.courseId + } + }, + include: { + course: { + select: { + title: true, + courseLink: true, + provider: { + select: { + orgName: true + } + } + } + } + } + }); + if(!userCourse) + throw new NotFoundException("User has not purchased this course"); + + // Forward to marketplace portal + const uri = process.env.MARKETPLACE_PORTAL_URL; + if(!uri) + throw new HttpException("Marketplace URL not set", 500); + + // Fetch user details from user service + + if(!process.env.USER_SERVICE_URL) + throw new HttpException("User service URL not defined", 500); + + let endpoint = `/api/mockFracService/user/${completeCourseDto.userId}`; + + const userResponse = await axios.get(process.env.USER_SERVICE_URL + endpoint); + + const customer = { + name: userResponse.data.data.userName, + phone: userResponse.data.data.phone || "+919999999999", + email: userResponse.data.data.email + } + + endpoint = `/api/consumer/course/complete`; + const courseCompletionDto = { + messageId: "123e4567-e89b-42d3-a456-556642440000", + transactionId: "123e4567-e89b-42d3-a456-556642440000", + bppId: "compass.bpp.course_manager", + bppUri: "course.backend.compass.samagra.io", + providerId: "123e4567-e89b-42d3-a456-556642440011", + providerName: userCourse.course.provider.orgName, + providerCourseId: userCourse.courseId, + providerOrderId: "123e4567-e89b-42d3-a456-556642440000", + courseName: userCourse.course.title, + courseLink: userCourse.course.courseLink, + customer, + price: {}, + status: "COMPLETED" + } + + await axios.patch(uri + endpoint, courseCompletionDto); + + // Update a course as complete for a purchased course + await this.prisma.userCourse.update({ + where: { + userId_courseId: { + courseId: completeCourseDto.courseId, + userId: completeCourseDto.userId, + } + }, + data: { + status: CourseProgressStatus.COMPLETED, + courseCompletionScore: completeCourseDto.courseCompletionScore + } + }) + + } + + async fetchAllCourses() : Promise { + + // Fetch all courses + const courses = await this.prisma.course.findMany({ + include: { + provider: { + select: { + orgName: true, + orgLogo: true + } + } + } + }); + return courses.map((c) => { + const { provider, competency, ...clone } = c; + return { + ...clone, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + providerName: provider.orgName, + providerLogo: provider.orgLogo + } + }); + } + + async acceptCourse(courseId: string, cqf_score?: number) { + + // Validate course + let course = await this.getCourse(courseId); + + // Check if the course verfication is pending + if(course.verificationStatus != CourseVerificationStatus.PENDING) { + throw new NotAcceptableException(`Course is either rejected or is already accepted.`); + } + // Update the course as accepted + const {competency, ...clone} = await this.prisma.course.update({ + where: { courseId }, + data: { + verificationStatus: CourseVerificationStatus.ACCEPTED, + cqfScore: cqf_score + } + }); + return { + ...clone, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + } + + async rejectCourse(courseId: string, rejectionReason: string) { + + // Validate course + const course = await this.getCourse(courseId); + + // Check if the course verfication is pending + if(course.verificationStatus != CourseVerificationStatus.PENDING) { + throw new NotAcceptableException(`Course is already rejected or is accepted`); + } + // Reject the course + const {competency, ...clone} = await this.prisma.course.update({ + where: { courseId }, + data: { + verificationStatus: CourseVerificationStatus.REJECTED, + rejectionReason: rejectionReason + } + }); + return { + ...clone, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + } + + async removeCourse(courseId: string) { + + // Validate course + await this.getCourse(courseId); + + // Delete course entry + const {competency, ...clone} = await this.prisma.course.delete({ + where: { courseId} + }); + return { + ...clone, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + } + + async getCourseTransactions(providerId: string): Promise { + + // Fetch course details and number of purchases + const transactions = await this.prisma.course.findMany({ + where: { + providerId + }, + select: { + courseId: true, + title: true, + startDate: true, + endDate: true, + credits: true, + _count: { + select: { + userCourses: true + } + } + }, + }); + + // Refactor to the DTO format required + return transactions.map((c) => { + return { + courseId: c.courseId, + courseName: c.title, + startDate: c.startDate, + endDate: c.endDate, + credits: c.credits, + numConsumersEnrolled: c._count.userCourses, + income: c.credits * c._count.userCourses + } + }); + } + + async recommendedCourses(competencies: string[]): Promise { + + if(typeof competencies == "string") + competencies = [competencies]; + + let competencyFilter = (competencies) ? competencies.map(( competency ) => { + + return { + competency: { + string_contains: competency + } + } + }): undefined; + let courses = await this.prisma.course.findMany({ + where: { + verificationStatus: CourseVerificationStatus.ACCEPTED, + status: CourseStatus.UNARCHIVED, + OR: competencyFilter + }, + include: { + provider: { + select: { + orgName: true, + } + }, + _count: { + select: { + userCourses: true + } + } + }, + }); + + // Filter out the courses that are not available + courses = courses.filter((c) => (c.startDate ? c.startDate <= new Date(): true) + && (c.endDate ? c.endDate >= new Date(): true) + ); + return courses.map((c) => { + let {cqfScore, impactScore, verificationStatus, rejectionReason, courseLink, provider, _count, competency, ...clone} = c; + + const courseResponse: CourseResponse = { + ...clone, + providerName: provider.orgName, + numOfUsers: _count.userCourses, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + return courseResponse; + }); + } + + + async mostPopularCourses(limit?: number, offset?: number): Promise { + + let courses = await this.prisma.course.findMany({ + where: { + verificationStatus: CourseVerificationStatus.ACCEPTED, + status: CourseStatus.UNARCHIVED, + }, + include: { + provider: { + select: { + orgName: true, + } + }, + _count: { + select: { + userCourses: true + } + } + }, + orderBy: { + avgRating: { + sort: "desc", + nulls: "last" + } + }, + skip: offset ? offset : 0, + take: limit ? limit : 10 + }); + + // Filter out the courses that are not available + courses = courses.filter((c) => (c.startDate ? c.startDate <= new Date(): true) + && (c.endDate ? c.endDate >= new Date(): true) + ); + return courses.map((c) => { + let {cqfScore, impactScore, verificationStatus, rejectionReason, courseLink, provider, _count, competency, ...clone} = c; + const courseResponse: CourseResponse = { + ...clone, + providerName: provider.orgName, + numOfUsers: _count.userCourses, + competency: (typeof competency == "string") ? JSON.parse(competency) : competency, + } + return courseResponse; + }); + } + + async filterVerified(courses: SearchResponseDTO[]) { + + return courses.filter(async (course) => { + const exists = await this.prisma.course.count({ + where: { + provider: { + name: course.provider_name + }, + title: course.title, + verificationStatus: CourseVerificationStatus.ACCEPTED + } + }); + return exists !== 0; + }); + + } +} diff --git a/src/course/dto/add-course.dto.ts b/src/course/dto/add-course.dto.ts new file mode 100644 index 0000000..2bf650e --- /dev/null +++ b/src/course/dto/add-course.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CourseStatus } from "@prisma/client"; +import { ArrayNotEmpty, IsArray, IsDate, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, IsUrl, Min } from "class-validator"; + +export class AddCourseDto { + + // course title + @ApiProperty() + @IsNotEmpty() + @IsString() + title: string; + + // description + @ApiProperty() + @IsNotEmpty() + @IsString() + description: string; + + // link for the course content + @ApiProperty() + @IsNotEmpty() + @IsUrl() + courseLink: string; + + + // number of credits required to purchase course + @ApiProperty() + @IsNotEmpty() + @Min(0) + @IsInt() + credits: number; + + // language + @ApiProperty() + @IsArray() + @ArrayNotEmpty() + language: string[]; + + // competency + @ApiProperty() + @IsNotEmpty() + @IsString() + competency: string; + + // author + @ApiProperty() + @IsNotEmpty() + @IsString() + author: string; + + // course status (archived/unarchived) + @ApiProperty() + @IsOptional() + @IsEnum(CourseStatus) + status?: CourseStatus; + + // course start date + @ApiProperty() + @IsDate() + @IsOptional() + startDate?: Date; + + // course end date + @ApiProperty() + @IsDate() + @IsOptional() + endDate?: Date; +} diff --git a/src/course/dto/completion.dto.ts b/src/course/dto/completion.dto.ts new file mode 100644 index 0000000..acb6eec --- /dev/null +++ b/src/course/dto/completion.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsInt, IsNotEmpty, IsNumber, IsUUID, Min } from "class-validator"; + + +export class CompleteCourseDto { + + // course ID + @ApiProperty() + @IsNotEmpty() + @IsUUID() + courseId: string; + + // user ID + @ApiProperty() + @IsNotEmpty() + @IsUUID() + userId: string; + + // Course completion score + @ApiProperty() + @IsNotEmpty() + @IsNumber() + @Min(0) + courseCompletionScore: number; +} \ No newline at end of file diff --git a/src/course/dto/course-response.dto.ts b/src/course/dto/course-response.dto.ts new file mode 100644 index 0000000..ecfe1f9 --- /dev/null +++ b/src/course/dto/course-response.dto.ts @@ -0,0 +1,36 @@ +import { CourseStatus, CourseVerificationStatus } from "@prisma/client"; +import { JsonValue } from "@prisma/client/runtime/library"; + +export class CourseResponse { + + readonly courseId: string; + readonly providerId: string; + readonly title: string; + readonly description: string; + readonly imageLink: string; + readonly credits: number; + readonly language: string[]; + readonly competency: JsonValue; + readonly author: string; + readonly avgRating: number | null; + readonly status: CourseStatus; + readonly startDate: Date | null; + readonly endDate: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly numOfUsers?: number; + readonly providerName?: string; +} + +export class ProviderCourseResponse extends CourseResponse { + readonly courseLink: string; + readonly verificationStatus: CourseVerificationStatus; + readonly rejectionReason: string | null; +} + +export class AdminCourseResponse extends ProviderCourseResponse { + + readonly cqfScore: number | null; + readonly impactScore: number | null; + readonly providerLogo: string; +} \ No newline at end of file diff --git a/src/course/dto/course-status.dto.ts b/src/course/dto/course-status.dto.ts new file mode 100644 index 0000000..d0f406c --- /dev/null +++ b/src/course/dto/course-status.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CourseStatus } from "@prisma/client"; +import { IsNotEmpty, IsEnum } from "class-validator"; + + +export class CourseStatusDto { + + // course status (archived/unarchived) + @ApiProperty() + @IsEnum(CourseStatus) + @IsNotEmpty() + status: CourseStatus; +} \ No newline at end of file diff --git a/src/course/dto/edit-course.dto.ts b/src/course/dto/edit-course.dto.ts new file mode 100644 index 0000000..90c82b6 --- /dev/null +++ b/src/course/dto/edit-course.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsDate, IsInt, IsOptional, IsString, IsUrl, Min } from "class-validator"; + +export class EditCourseDto { + + // course title + @ApiProperty() + @IsString() + @IsOptional() + title?: string; + + // description + @ApiProperty() + @IsString() + @IsOptional() + description?: string; + + // link for the course content + @ApiProperty() + @IsUrl() + @IsOptional() + courseLink?: string; + + // course image + @ApiProperty() + @IsUrl() + @IsOptional() + imageLink?: string; + + // number of credits required to purchase course + @ApiProperty() + @Min(0) + @IsInt() + @IsOptional() + credits?: number; + + // Number of lessons + @ApiProperty() + @IsInt() + @IsOptional() + noOfLessons?: number; + + // language + @ApiProperty() + @IsArray() + @IsOptional() + language?: string[]; + + // competency + @ApiProperty() + @IsOptional() + @IsString() + competency?: string; + + // author + @ApiProperty() + @IsString() + @IsOptional() + author?: string; + + // course start date + @ApiProperty() + @IsDate() + @IsOptional() + startDate?: Date; + + // course end date + @ApiProperty() + @IsDate() + @IsOptional() + endDate?: Date; +} \ No newline at end of file diff --git a/src/course/dto/feedback.dto.ts b/src/course/dto/feedback.dto.ts new file mode 100644 index 0000000..3e917fb --- /dev/null +++ b/src/course/dto/feedback.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class FeedbackDto { + + // Feedback Text + @ApiProperty() + @IsOptional() + @IsString() + feedback?: string; + + // Integer rating of the course + @ApiProperty() + @IsNotEmpty() + @IsInt() + rating: number; +} diff --git a/src/course/dto/purchase.dto.ts b/src/course/dto/purchase.dto.ts new file mode 100644 index 0000000..316cb7c --- /dev/null +++ b/src/course/dto/purchase.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsUUID } from "class-validator"; + + +export class PurchaseDto { + // consumer ID + @ApiProperty() + @IsNotEmpty() + @IsUUID() + consumerId: string; +} + +export class PurchaseResponseDto { + readonly courseLink: string; +} + +export class WalletPurchaseDto { + readonly providerId: string; + readonly credits: number; + readonly description: string; +} \ No newline at end of file diff --git a/src/course/dto/search-response.dto.ts b/src/course/dto/search-response.dto.ts new file mode 100644 index 0000000..a3c66f6 --- /dev/null +++ b/src/course/dto/search-response.dto.ts @@ -0,0 +1,17 @@ +import { JsonValue } from "@prisma/client/runtime/library"; + +export class SearchResponseDTO { + readonly id: string; + readonly title: string; + readonly long_desc: string; + readonly provider_name: string; + readonly provider_id: string; + readonly price: string; + readonly languages: string[]; + readonly competency: JsonValue; + readonly imgUrl: string; + readonly rating: string; + readonly startTime: string; + readonly endTime: string; + readonly noOfPurchases: number; +} \ No newline at end of file diff --git a/src/course/dto/transaction.dto.ts b/src/course/dto/transaction.dto.ts new file mode 100644 index 0000000..f39875f --- /dev/null +++ b/src/course/dto/transaction.dto.ts @@ -0,0 +1,26 @@ + + +export class CourseTransactionDto { + + // course ID + readonly courseId: string; + + // course name + readonly courseName: string; + + // timestamp for when the course availability starts + readonly startDate: Date | null; + + // timestamp for when the course availability ends + readonly endDate: Date | null; + + // Credit cost of course + readonly credits: number; + + // Number of enrolled consumers + readonly numConsumersEnrolled: number; + + // Total income from course + readonly income: number; +} + diff --git a/src/course/dto/verify-course.dto.ts b/src/course/dto/verify-course.dto.ts new file mode 100644 index 0000000..a6bc10c --- /dev/null +++ b/src/course/dto/verify-course.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; + + +export class CourseVerify { + @ApiProperty() + @IsNumber() + @IsOptional() + cqf_score?: number; +} \ No newline at end of file diff --git a/src/mock-wallet/mock-wallet.controller.spec.ts b/src/mock-wallet/mock-wallet.controller.spec.ts new file mode 100644 index 0000000..3ccb1b7 --- /dev/null +++ b/src/mock-wallet/mock-wallet.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MockWalletController } from './mock-wallet.controller'; + +describe('MockWalletController', () => { + let controller: MockWalletController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MockWalletController], + }).compile(); + + controller = module.get(MockWalletController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/mock-wallet/mock-wallet.controller.ts b/src/mock-wallet/mock-wallet.controller.ts new file mode 100644 index 0000000..9bea609 --- /dev/null +++ b/src/mock-wallet/mock-wallet.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('mock-wallet') +export class MockWalletController {} diff --git a/src/mock-wallet/mock-wallet.module.ts b/src/mock-wallet/mock-wallet.module.ts new file mode 100644 index 0000000..8d75d1d --- /dev/null +++ b/src/mock-wallet/mock-wallet.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MockWalletController } from './mock-wallet.controller'; +import { MockWalletService } from './mock-wallet.service'; + +@Module({ + controllers: [MockWalletController], + providers: [MockWalletService], + exports: [MockWalletService] +}) +export class MockWalletModule {} diff --git a/src/mock-wallet/mock-wallet.service.spec.ts b/src/mock-wallet/mock-wallet.service.spec.ts new file mode 100644 index 0000000..5871d3c --- /dev/null +++ b/src/mock-wallet/mock-wallet.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MockWalletService } from './mock-wallet.service'; + +describe('MockWalletService', () => { + let service: MockWalletService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MockWalletService], + }).compile(); + + service = module.get(MockWalletService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/mock-wallet/mock-wallet.service.ts b/src/mock-wallet/mock-wallet.service.ts new file mode 100644 index 0000000..3f725f9 --- /dev/null +++ b/src/mock-wallet/mock-wallet.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MockWalletService { + + getTransactions(adminId: number) { + return { + status: 200, + data: [ + { + transactionId: 1, + fromId: adminId, + toId: 2, + credits: 120, + type: 'creditRequest', + descripiton: `A credit request of 120 credits was made from consumer with id 2`, + createdAT: new Date() + }, + { + transactionId: 4, + fromId: adminId, + toId: 3, + credits: 100, + type: 'creditRequest', + descripiton: `A credit request of 100 credits was made from consumer with id 3`, + createdAT: new Date() + }, + ] + } + } + + addCredits(adminId: number, providerId: number, credits: number) { + return { + message: "Credits added successfully for the providers' wallet", + data: { + credits: credits + } + } + } + + reduceCredits(adminId: number, providerId: number, credits: number) { + return { + message: "Credits removed successfully from the providers' wallet", + data: { + credits: -credits + } + } + } +} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index e6d1e9e..c98d17c 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,4 +1,9 @@ import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { ConfigService } from '@nestjs/config'; -@Module({}) +@Module({ + providers: [PrismaService, ConfigService], + exports: [PrismaService] +}) export class PrismaModule {} diff --git a/src/prisma/prisma.service.spec.ts b/src/prisma/prisma.service.spec.ts index a68cb9e..f1b91f3 100644 --- a/src/prisma/prisma.service.spec.ts +++ b/src/prisma/prisma.service.spec.ts @@ -1,12 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from './prisma.service'; +import { ConfigService } from '@nestjs/config'; describe('PrismaService', () => { let service: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [PrismaService], + providers: [PrismaService, ConfigService], }).compile(); service = module.get(PrismaService); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 5a993ff..fe30953 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,5 +1,16 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { PrismaClient } from '@prisma/client'; @Injectable() -export class PrismaService extends PrismaClient{} +export class PrismaService extends PrismaClient{ + constructor(private config: ConfigService) { + super({ + datasources: { + db: { + url: config.get("DATABASE_URL"), + }, + }, + }); + } +} diff --git a/src/provider/dto/feedback.dto.ts b/src/provider/dto/feedback.dto.ts new file mode 100644 index 0000000..6b6b1ae --- /dev/null +++ b/src/provider/dto/feedback.dto.ts @@ -0,0 +1,19 @@ + + +export class FeedbackResponseDto { + + // number of consumers that have purchased the course + readonly numberOfPurchases: number + + // list of consumer feedbacks and their ratings + readonly feedbacks: Feedback[] +} + +export interface Feedback { + + // Feedback Text + feedback: string, + + // Integer rating of the course + rating: number +} \ No newline at end of file diff --git a/src/provider/dto/login.dto.ts b/src/provider/dto/login.dto.ts new file mode 100644 index 0000000..a323958 --- /dev/null +++ b/src/provider/dto/login.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsStrongPassword } from "class-validator"; + +export class CheckRegDto { + + @ApiProperty() + @IsNotEmpty() + @IsEmail() + email: string +} + +export class CheckRegResponseDto { + readonly found: boolean +} + +export class LoginDto { + + @ApiProperty() + @IsNotEmpty() + @IsEmail() + email: string + + @ApiProperty() + @IsNotEmpty({ message: 'Password is required' }) + password: string +} + +export class LoginResponseDto { + + // provider ID + readonly providerId: string +} \ No newline at end of file diff --git a/src/provider/dto/provider-profile-response.dto.ts b/src/provider/dto/provider-profile-response.dto.ts new file mode 100644 index 0000000..c3db65e --- /dev/null +++ b/src/provider/dto/provider-profile-response.dto.ts @@ -0,0 +1,65 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ProviderStatus } from "@prisma/client"; +import { JsonValue } from "@prisma/client/runtime/library"; +import { IsEmail, IsEnum, IsObject, IsString, IsUUID, IsOptional, IsUrl, IsPhoneNumber, IsDate, IsNotEmpty } from 'class-validator'; + +export class ProviderProfileResponse { + @ApiProperty({required: false}) + @IsUUID() + @IsNotEmpty() + id: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({format: 'email'}) + @IsEmail() + @IsNotEmpty() + email: string; + + // organisation name + @ApiProperty() + @IsNotEmpty() + @IsString() + orgName: string; + + // organisation logo image link + @ApiProperty() + @IsNotEmpty() + @IsUrl() + orgLogo: string; + + // phone number + @ApiProperty() + @IsNotEmpty() + @IsPhoneNumber() + phone: string; + + @ApiProperty() + @IsOptional() + @IsObject() + paymentInfo: JsonValue; + + @ApiProperty() + @IsNotEmpty() + @IsEnum(ProviderStatus) + status: ProviderStatus; + + @ApiProperty() + @IsNotEmpty() + @IsString() + rejectionReason: string | null; + // readonly courses: Course[]; + + @ApiProperty() + @IsNotEmpty() + @IsDate() + createdAt: Date; + + @ApiProperty() + @IsNotEmpty() + @IsDate() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/provider/dto/signup.dto.ts b/src/provider/dto/signup.dto.ts new file mode 100644 index 0000000..301b88a --- /dev/null +++ b/src/provider/dto/signup.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsStrongPassword } from "class-validator"; + +export class SignupDto { + + // name + @ApiProperty() + @IsNotEmpty() + @IsString() + name: string + + // email ID + @ApiProperty() + @IsNotEmpty() + @IsEmail() + email: string + + // password + @ApiProperty() + // A strong password should have a minimum length of 8 characters, + // at least 1 uppercase letter, 1 lowercase letter, 1 number, and 1 special symbol. + @IsNotEmpty({ message: 'Password is required' }) + @IsStrongPassword( + { + minLength: 8, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }, + { message: 'Password is not strong enough' }, + ) + password: string; + + // organisation name + @ApiProperty() + @IsNotEmpty() + @IsString() + orgName: string; + + // phone number + @ApiProperty() + @IsNotEmpty() + @IsPhoneNumber() + phone: string; + + // payment info + @ApiProperty() + @IsOptional() + @IsString() + paymentInfo?: string +} + +export class SignupResponseDto { + + // provider ID + readonly providerId: string +} \ No newline at end of file diff --git a/src/provider/dto/update-password.dto.ts b/src/provider/dto/update-password.dto.ts new file mode 100644 index 0000000..0ea9f19 --- /dev/null +++ b/src/provider/dto/update-password.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsStrongPassword } from "class-validator"; + +export class UpdatePasswordDto { + // old password + @ApiProperty() + @IsNotEmpty({ message: "Old Password is required" }) + oldPassword: string; + + // new password + @ApiProperty() + @IsNotEmpty({ message: "New Password is required" }) + @IsStrongPassword() + newPassword: string; +} \ No newline at end of file diff --git a/src/provider/dto/update-profile.dto.ts b/src/provider/dto/update-profile.dto.ts new file mode 100644 index 0000000..5f5f28f --- /dev/null +++ b/src/provider/dto/update-profile.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsOptional, IsPhoneNumber, IsString } from "class-validator"; + +export class UpdateProfileDto { + + // name + @ApiProperty() + @IsString() + @IsOptional() + name?: string + + // email ID + @ApiProperty() + @IsEmail() + @IsOptional() + email?: string + + // organisation name + @ApiProperty() + @IsString() + @IsOptional() + orgName?: string + + // phone number + @ApiProperty() + @IsPhoneNumber() + @IsOptional() + phone?: string + + // payment info + @ApiProperty() + @IsOptional() + @IsString() + paymentInfo?: string +} diff --git a/src/provider/provider.controller.ts b/src/provider/provider.controller.ts new file mode 100644 index 0000000..513b4d4 --- /dev/null +++ b/src/provider/provider.controller.ts @@ -0,0 +1,470 @@ +import { Body, Controller, Delete, Get, HttpStatus, Logger, Param, ParseUUIDPipe, Patch, Post, Put, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { ProviderService } from './provider.service'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { SignupDto, SignupResponseDto } from './dto/signup.dto'; +import { CheckRegDto, CheckRegResponseDto, LoginDto, LoginResponseDto } from './dto/login.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { AddCourseDto } from 'src/course/dto/add-course.dto'; +import { FeedbackResponseDto } from './dto/feedback.dto'; +import { CourseTransactionDto } from '../course/dto/transaction.dto'; +import { CompleteCourseDto } from 'src/course/dto/completion.dto'; +import { EditCourseDto } from 'src/course/dto/edit-course.dto'; +import { ProviderCourseResponse } from 'src/course/dto/course-response.dto'; +import { ProviderProfileResponse } from './dto/provider-profile-response.dto'; +import { getPrismaErrorStatusAndMessage } from 'src/utils/utils'; +import { UpdatePasswordDto } from './dto/update-password.dto'; +import { CourseStatusDto } from 'src/course/dto/course-status.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@Controller('provider') +@ApiTags('provider') +export class ProviderController { + + private readonly logger = new Logger(ProviderController.name); + + constructor( + private providerService: ProviderService, + ) {} + + @ApiOperation({ summary: 'Check if provider is registered' }) + @ApiResponse({ status: HttpStatus.OK, type: CheckRegResponseDto }) + @Post() + // Check if provider is registered + async checkProviderReg( + @Body() checkRegDto: CheckRegDto, + @Res() res + ) { + try { + this.logger.log(`Checking provider email`); + + const found = await this.providerService.checkProviderFromEmail(checkRegDto.email); + + this.logger.log(`Successfully checked provider`); + + res.status(HttpStatus.OK).json({ + message: "Check successful", + data: found + }) + } catch (err) { + this.logger.error(`Failed to check provider: `,err.message); + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to check provider", + }); + } + } + + @ApiOperation({ summary: 'create provider account' }) + @ApiResponse({ status: HttpStatus.CREATED, type: SignupResponseDto }) + @Post("/signup") + @UseInterceptors(FileInterceptor('logo')) + // create a new provider account + async createAccount( + @Body() signupDto: SignupDto, + @UploadedFile() logo: Express.Multer.File, + @Res() res + ) { + try { + this.logger.log(`Creating new provider account`); + + const providerId = await this.providerService.createNewAccount(signupDto, logo); + + this.logger.log(`successfully created new provider account`); + + res.status(HttpStatus.CREATED).json({ + message: "account created successfully", + data: { + providerId + } + }) + } catch (err) { + this.logger.error(`Failed to create new provider account: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to create new provider account`", + }); + } + } + + @ApiOperation({ summary: 'provider login' }) + @ApiResponse({ status: HttpStatus.OK, type: LoginResponseDto }) + @Post("/login") + // provider login + async login( + @Body() loginDto: LoginDto, + @Res() res + ) { + try { + this.logger.log(`Getting provider ID`); + + const providerId = await this.providerService.getProviderIdFromLogin(loginDto); + + this.logger.log(`successfully logged in`); + + res.status(HttpStatus.OK).json({ + message: "login successful", + data: { + providerId + } + }) + } catch (err) { + this.logger.error(`Failed to log in: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to log in`", + }); + } + } + + @ApiOperation({ summary: 'view provider profile' }) + @ApiResponse({ status: HttpStatus.OK, type: ProviderProfileResponse }) + @Get("/:providerId/profile") + // view provider profile information + async viewProfile( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Res() res + ) { + try { + this.logger.log(`Getting provider profile`); + + const provider = await this.providerService.getProvider(providerId); + + this.logger.log(`successfully retreived provider profile`); + + res.status(HttpStatus.OK).json({ + message: "fetch successful", + data : provider + }) + } catch (err) { + this.logger.error(`Failed to retreive provider profile: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to retreive provider profile`", + }); + } + } + + @ApiOperation({ summary: 'update provider profile information' }) + @ApiResponse({ status: HttpStatus.OK }) + @Put("/:providerId/profile") + @UseInterceptors(FileInterceptor('logo')) + // update provider profile information + async updateProfile( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Body() updateProfileDto: UpdateProfileDto, + @UploadedFile() logo: Express.Multer.File, + @Res() res + ) { + try { + this.logger.log(`Updating provider profile`); + + await this.providerService.updateProfileInfo(providerId, updateProfileDto, logo); + + this.logger.log(`successfully updated provider profile`); + + res.status(HttpStatus.OK).json({ + message: "account updated successfully", + }) + } catch (err) { + this.logger.error(`Failed to update provider profile: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to update provider profile`", + }); + } + } + + @ApiOperation({ summary: 'add new course' }) + @ApiResponse({ status: HttpStatus.CREATED, type: ProviderCourseResponse }) + @Post("/:providerId/course") + @UseInterceptors(FileInterceptor('image', { + limits: { + fileSize: 1024 * 1024 * 6, + } + })) + // add new course + async addCourse( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Body() addCourseDto: AddCourseDto, + @UploadedFile() image: Express.Multer.File, + @Res() res + ) { + try { + this.logger.log(`Adding new course`); + + const course = await this.providerService.addNewCourse(providerId, addCourseDto, image); + + this.logger.log(`Successfully added new course`); + + res.status(HttpStatus.CREATED).json({ + message: "course added successfully", + data: course + }) + } catch (err) { + this.logger.error(`Failed to add the course: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to add the course`", + }); + } + } + + @ApiOperation({ summary: 'View courses offered by self' }) + @ApiResponse({ status: HttpStatus.OK, type: [ProviderCourseResponse] }) + @Get("/:providerId/course") + // View courses offered by self + async fetchProviderCourses( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Res() res + ) { + try { + this.logger.log(`Getting courses`); + + const courses = await this.providerService.getCourses(providerId); + + this.logger.log(`Successfully retrieved the courses`); + + res.status(HttpStatus.OK).json({ + message: "courses fetched successfully", + data: courses + }) + } catch (err) { + this.logger.error(`Failed to fetch the courses: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch the courses", + }); + } + } + + @ApiOperation({ summary: 'Get transactions of all courses' }) + @ApiResponse({ status: HttpStatus.OK, type: [CourseTransactionDto] }) + @Get("/:providerId/course/transactions") + // Get transactions of all courses + async getCourseTransactions( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Res() res + ) { + try { + this.logger.log(`Getting course transactions`); + + const transactionsResponse = await this.providerService.getCourseTransactions(providerId); + + this.logger.log(`Successfully retrieved course transactions`); + + res.status(HttpStatus.OK).json({ + message: "transactions fetched successfully", + data: transactionsResponse + }) + } catch (err) { + this.logger.error(`Failed to fetch the transactions: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch the transactions", + }); + } + } + + @ApiOperation({ summary: 'Mark course as complete' }) + @ApiResponse({ status: HttpStatus.OK }) + @Patch("/:providerId/course/completion") + // Mark course as complete for a user + async markCourseComplete( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Body() completeCourseDto: CompleteCourseDto, + @Res() res + ) { + try { + this.logger.log(`Updating course as complete`); + + await this.providerService.markCourseComplete(providerId, completeCourseDto); + + this.logger.log(`Successfully marked the course as complete`); + + res.status(HttpStatus.OK).json({ + message: "course marked complete", + }) + } catch (err) { + this.logger.error(`Failed to mark the course completion: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to mark the course completion", + }); + } + } + + @ApiOperation({ summary: 'edit course information' }) + @ApiResponse({ status: HttpStatus.OK }) + @Patch("/:providerId/course/:courseId") + @UseInterceptors(FileInterceptor('image')) + // edit course information + async editCourse( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Param("courseId", ParseUUIDPipe) courseId: string, + @UploadedFile() image: Express.Multer.File, + @Body() editCourseDto: EditCourseDto, + @Res() res + ) { + try { + this.logger.log(`Updating course information`); + + await this.providerService.editCourse(providerId, courseId, editCourseDto, image); + + this.logger.log(`Successfully updated course information`); + + res.status(HttpStatus.OK).json({ + message: "course edited successfully", + }) + } catch (err) { + this.logger.error(`Failed to update course information: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to update course information`", + }); + } + } + + @ApiOperation({ summary: 'remove a course' }) + @ApiResponse({ status: HttpStatus.OK }) + @Delete("/:providerId/course/:courseId") + // remove an existing course + async removeCourse( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Param("courseId", ParseUUIDPipe) courseId: string, + @Res() res + ) { + try { + this.logger.log(`Removing course`); + + await this.providerService.removeCourse(providerId, courseId); + + this.logger.log(`Successfully deleted the course`); + + + res.status(HttpStatus.OK).json({ + message: "course deleted successfully", + }) + } catch (err) { + this.logger.error(`Failed to delete the course: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to delete the course`", + }); + } + } + + @ApiOperation({ summary: 'change course status' }) + @ApiResponse({ status: HttpStatus.OK }) + @Patch("/:providerId/course/:courseId/status") + // change course status (archived/unarchived) + async changeCourseStatus( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Param("courseId", ParseUUIDPipe) courseId: string, + @Body() courseStatusDto: CourseStatusDto, + @Res() res + ) { + try { + this.logger.log(`Changing course status`); + + await this.providerService.changeCourseStatus(providerId, courseId, courseStatusDto); + + this.logger.log(`Successfully changed course status`); + + res.status(HttpStatus.OK).json({ + message: "course status changed successfully", + }) + } catch (err) { + this.logger.error(`Failed to change course status: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to change course status`", + }); + } + } + + @ApiOperation({ summary: 'View Course Feedback & ratings, numberOfPurchases' }) + @ApiResponse({ status: HttpStatus.OK, type: FeedbackResponseDto }) + @Get("/:providerId/course/:courseId/feedback") + // View Course Feedback & ratings, numberOfPurchases + async getCourseFeedback( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Param("courseId", ParseUUIDPipe) courseId: string, + @Res() res + ) { + try { + this.logger.log(`Getting course feedbacks`); + + const feedbackResponse = await this.providerService.getCourseFeedbacks(providerId, courseId); + + this.logger.log(`Successfully retrieved the feedbacks`); + + res.status(HttpStatus.OK).json({ + message: "feedbacks fetched successfully", + data: feedbackResponse + }) + } catch (err) { + this.logger.error(`Failed to fetch the feedbacks: `,err.message); + + const {errorMessage, statusCode} = getPrismaErrorStatusAndMessage(err); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to fetch the feedbacks", + }); + } + } + + @ApiOperation({ summary: "Reset password" }) + @ApiResponse({ status: HttpStatus.OK }) + @Patch("/:providerId/reset-password") + // Reset Password + async resetPassword( + @Param("providerId", ParseUUIDPipe) providerId: string, + @Body() updatePasswordDto: UpdatePasswordDto, + @Res() res + ) { + try { + this.logger.log("Reseting the password of the provider."); + await this.providerService.updateProviderPassword( + providerId, + updatePasswordDto + ); + this.logger.log(`Successfully reset the password.`); + + res.status(HttpStatus.OK).json({ + message: "Successfully reset the password.", + }); + } catch (error) { + this.logger.error(`Failed to reset the password: `,error.message); + + const { errorMessage, statusCode } = + getPrismaErrorStatusAndMessage(error); + res.status(statusCode).json({ + statusCode, + message: errorMessage || "Failed to reset the password.", + }); + } + } +} \ No newline at end of file diff --git a/src/provider/provider.module.ts b/src/provider/provider.module.ts new file mode 100644 index 0000000..f45ec31 --- /dev/null +++ b/src/provider/provider.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ProviderController } from './provider.controller'; +import { ProviderService } from './provider.service'; +import { CourseService } from 'src/course/course.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [ProviderController], + providers: [ProviderService, CourseService, PrismaService], + exports: [ProviderService] +}) +export class ProviderModule {} \ No newline at end of file diff --git a/src/provider/provider.service.ts b/src/provider/provider.service.ts new file mode 100644 index 0000000..8a48198 --- /dev/null +++ b/src/provider/provider.service.ts @@ -0,0 +1,426 @@ +import { BadRequestException, HttpException, Injectable, NotAcceptableException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { SignupDto } from './dto/signup.dto'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { ProviderStatus } from '@prisma/client'; +import { CheckRegResponseDto, LoginDto } from './dto/login.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { AddCourseDto } from 'src/course/dto/add-course.dto'; +import { CourseService } from 'src/course/course.service'; +import { Feedback, FeedbackResponseDto } from './dto/feedback.dto'; +import { CourseTransactionDto } from '../course/dto/transaction.dto'; +import { CompleteCourseDto } from 'src/course/dto/completion.dto'; +import { EditCourseDto } from 'src/course/dto/edit-course.dto'; +import { EditProvider } from 'src/admin/dto/edit-provider.dto'; +import { ProviderCourseResponse } from 'src/course/dto/course-response.dto'; +import { ProviderProfileResponse } from './dto/provider-profile-response.dto'; +import { AuthService } from 'src/auth/auth.service'; +import axios from 'axios'; +import { UpdatePasswordDto } from './dto/update-password.dto'; +import { CourseStatusDto } from 'src/course/dto/course-status.dto'; +import { ProviderSettlementDto } from 'src/admin/dto/provider-settlement.dto'; +import { uploadFile } from 'src/utils/minio'; + + +@Injectable() +export class ProviderService { + constructor( + private prisma: PrismaService, + private courseService: CourseService, + private authService: AuthService + ) {} + + async createNewAccount(signupDto: SignupDto, logo: Express.Multer.File) { + + // Check if email already exists + let provider = await this.prisma.provider.findUnique({ + where : { + email: signupDto.email + } + }); + + if(provider) + throw new BadRequestException("Account with that email ID already exists"); + + + // check if there is a user with provider role in the user service + const baseUrl = process.env.USER_SERVICE_URL || ""; + const headers = { + 'Authorization': 'bearer ' + process.env.USER_SERVICE_TOKEN, + 'Content-Type': 'application/json', + 'Cookie': process.env.USER_SERVICE_COOKIE + }; + + const data = { + "request": { + "filters": { + "profileDetails.personalDetails.primaryEmail": signupDto.email + } + } + }; + + let response = await axios.post(baseUrl, data, {headers}); + + if(response.data.result?.response?.content[0]?.organisations[0]?.roles?.filter(role => role == "CONTENT_CREATOR").length != 0) { + // Hashing the password + const hashedPassword = await this.authService.hashPassword(signupDto.password); + + if(!logo) + throw new BadRequestException("Logo not uploaded"); + + // upload the image to minio + const imageLink = await uploadFile(`provider/${signupDto.orgName.replaceAll(" ", "_")}/logo`, logo.buffer) + + // Create an entry in the database + provider = await this.prisma.provider.create({ + data: { + id: response.data.result?.response?.content[0]?.userId, + name: signupDto.name, + email: signupDto.email, + password: hashedPassword, + paymentInfo: signupDto.paymentInfo ? JSON.parse(signupDto.paymentInfo) : undefined, + orgName: signupDto.orgName, + orgLogo: imageLink, + phone: signupDto.phone + } + }); + try { + // Forward to wallet service for creation of wallet + if(!process.env.WALLET_SERVICE_URL) + throw new HttpException("Wallet service URL not defined", 500); + const url = process.env.WALLET_SERVICE_URL; + const endpoint = url + `/api/wallet/create`; + const reqBody = { + userId: provider.id, + type: 'PROVIDER', + credits: 0 + } + const resp = await axios.post(endpoint, reqBody); + } catch(err) { + await this.prisma.provider.delete({ + where: { + id: provider.id + } + }); + throw new HttpException(err.response || "Wallet service not running", err.response?.status || err.status || 500); + } + return provider.id + } else { + throw new HttpException("User with the given email does not exist in the user service with the role as 'PROVIDER'", 400) + } + } + + async getProviderIdFromLogin(loginDto: LoginDto) { + + // Fetch the provider from email ID + const provider = await this.prisma.provider.findUnique({ + where: { + email: loginDto.email + } + }); + if(!provider) + throw new NotFoundException("provider not found"); + + // Compare the entered password with the password fetched from database + const isPasswordValid = await this.authService.comparePasswords(loginDto.password, provider.password); + + if(!isPasswordValid) + throw new BadRequestException("Incorrect password"); + + return provider.id + } + + async getProvider(providerId: string) { + + // Fetch provider details using ID + const provider = await this.prisma.provider.findUnique({ + where: { + id: providerId + } + }); + if(!provider) + throw new NotFoundException("provider does not exist"); + + const { password, ...clone } = provider; + return clone; + } + + async checkProviderFromEmail(email: string): Promise { + + // Fetch provider details using email ID + const provider = await this.prisma.provider.findUnique({ + where: { + email + } + }); + if(!provider) + return { found: false } + + return { found: true }; + } + + // Used when provider makes a request to update profile + async updateProfileInfo(providerId: string, updateProfileDto: UpdateProfileDto, logo?: Express.Multer.File) { + + // Fetch provider + const provider = await this.getProvider(providerId); + let imageLink = provider.orgLogo; + if(logo) { + // upload the image to minio + const imageName = updateProfileDto.orgName ?? provider.orgName; + imageLink = await uploadFile(`provider/${imageName.replaceAll(" ", "_")}/logo`, logo.buffer) + } + const { paymentInfo, ...clone } = updateProfileDto; + await this.prisma.provider.update({ + where: { + id: providerId + }, + data: { + ...clone, + paymentInfo: paymentInfo ? JSON.parse(paymentInfo) : undefined, + orgLogo: imageLink + } + }) + } + + // Used when admin makes a request to update provider profile + async editProviderProfileByAdmin(profileInfo: EditProvider) { + + return this.prisma.provider.update({ + where: { id: profileInfo.id }, + data: profileInfo + }); + } + + async addNewCourse(providerId: string, addCourseDto: AddCourseDto, image: Express.Multer.File): Promise { + + // Fetch provider + const provider = await this.getProvider(providerId); + + // Check verification + if(provider.status != ProviderStatus.VERIFIED) + throw new UnauthorizedException("Provider account is not verified"); + + if(!image) + throw new BadRequestException("Image not uploaded"); + + // Forward to course service + const {cqfScore, impactScore, ...clone} = await this.courseService.addCourse(addCourseDto, provider, image); + return clone; + } + + async removeCourse(providerId: string, courseId: string) { + + // Validate course ID provided + const course = await this.courseService.getCourse(courseId); + if(!course) + throw new NotFoundException("Course does not exist"); + + if(course.providerId != providerId) + throw new BadRequestException("Course does not belong to this provider"); + + // Forward to course service + await this.courseService.deleteCourse(courseId); + } + + async getCourses(providerId: string): Promise { + + return this.courseService.getProviderCourses(providerId); + } + + async editCourse(providerId: string, courseId: string, editCourseDto: EditCourseDto, image?: Express.Multer.File) { + + // Validate provider + const provider = await this.getProvider(providerId); + + return this.courseService.editCourse(courseId, editCourseDto, provider, image); + } + + async changeCourseStatus(providerId: string, courseId: string, courseStatusDto: CourseStatusDto) { + + // Validate provider + await this.getProvider(providerId); + + return this.courseService.changeStatus(courseId, providerId, courseStatusDto); + } + + async getCourseFeedbacks(providerId: string, courseId: string): Promise { + + // Fetch course + const course = await this.courseService.getCourse(courseId); + + // Validate course with provider + if(course.providerId != providerId) + throw new BadRequestException("Course does not belong to this provider"); + + // Forward to course service + const userCourses = await this.courseService.getPurchasedUsersByCourseId(courseId); + + // Construction of DTO required for response + let feedbacks: Feedback[] = []; + for(let u of userCourses) { + if(u.feedback && u.rating) { + feedbacks.push({ + feedback: u.feedback, + rating: u.rating + }) + } + } + return { + numberOfPurchases: userCourses.length, + feedbacks + }; + } + + async getCourseTransactions(providerId: string): Promise { + + return this.courseService.getCourseTransactions(providerId) + } + + async markCourseComplete(providerId: string, completeCourseDto: CompleteCourseDto) { + + // Validate course ID provided + const course = await this.courseService.getCourse(completeCourseDto.courseId); + if(!course) + throw new NotFoundException("Course does not exist"); + + if(course.providerId != providerId) + throw new BadRequestException("Course does not belong to this provider"); + + // Forward to course service + await this.courseService.markCourseComplete(completeCourseDto); + + } + + async fetchAllProviders(): Promise { + + const providers = await this.prisma.provider.findMany(); + + return providers.map((p) => { + return { + id: p.id, + name: p.name, + email: p.email, + paymentInfo: (typeof p.paymentInfo === "string") ? JSON.parse(p.paymentInfo) : p.paymentInfo ?? undefined, + rejectionReason: p.rejectionReason, + status: p.status, + orgLogo: p.orgLogo, + orgName: p.orgName, + phone: p.phone, + createdAt: p.createdAt, + updatedAt: p.updatedAt + } + }) + } + + async fetchProvidersForSettlement(): Promise { + + const providers = await this.prisma.provider.findMany({ + select: { + id: true, + orgLogo: true, + orgName: true, + courses: { + select: { + userCourses: { + select: { + userId: true + } + } + }, + } + } + }); + const results = providers.map(async (provider): Promise => { + const providerId = provider.id; + const courses = provider.courses; + const activeUsers = new Set(); + courses.forEach((c) => { + c.userCourses.forEach((uc) => { + activeUsers.add(uc.userId); + }) + }) + return { + id: providerId, + name: provider.orgName, + imageLink: provider.orgLogo, + totalCourses: courses.length, + activeUsers: activeUsers.size + } + }) + return Promise.all(results); + } + + async verifyProvider(providerId: string) { + + // Fetch provider + let providerInfo = await this.getProvider(providerId); + + // Check if provider verification is pending + if(providerInfo.status != ProviderStatus.PENDING) { + throw new NotAcceptableException(`Provider is either verified or rejected.`); + } + // Update the status in database + return this.prisma.provider.update({ + where: {id: providerId}, + data: { + status: ProviderStatus.VERIFIED, + updatedAt: new Date() + } + }); + } + + async rejectProvider(providerId: string, rejectionReason: string) { + + // Fetch provider + let providerInfo = await this.getProvider(providerId); + + // Check if provider verification is pending + if(providerInfo.status != ProviderStatus.PENDING) { + throw new NotAcceptableException(`Provider is either already accepted or rejected`); + } + // Update the status in database + return this.prisma.provider.update({ + where: {id: providerId}, + data: { + status: ProviderStatus.REJECTED, + rejectionReason: rejectionReason, + updatedAt: new Date() + } + }); + } + + async updateProviderPassword( + providerId: string, + updatePasswordDto: UpdatePasswordDto + ) { + // Fetch provider details using ID + const provider = await this.prisma.provider.findUnique({ + where: { + id: providerId + } + }); + if(!provider) + throw new NotFoundException("provider does not exist"); + + // Compare the entered old password with the password fetched from database + const isPasswordValid = await this.authService.comparePasswords( + updatePasswordDto.oldPassword, + provider.password + ); + + if (!isPasswordValid) throw new BadRequestException("Incorrect password"); + // Hashing the password + const hashedPassword = await this.authService.hashPassword( + updatePasswordDto.newPassword + ); + // Updating the password to the newly generated one + return this.prisma.provider.update({ + where: { + id: providerId, + }, + data: { + password: hashedPassword, + }, + }); + } +} \ No newline at end of file diff --git a/src/utils/minio.ts b/src/utils/minio.ts new file mode 100644 index 0000000..2de49cf --- /dev/null +++ b/src/utils/minio.ts @@ -0,0 +1,40 @@ +import * as Minio from 'minio'; + +const endPoint = process.env.MINIO_ENDPOINT!; + +// Replace these values with your Minio server details +const minioClient = new Minio.Client({ + endPoint: endPoint, + port: 9000, + useSSL: false, + accessKey: process.env.MINIO_ACCESS_KEY!, + secretKey: process.env.MINIO_SECRET_KEY!, +}); + +// Replace these values with your bucket and object details +const bucketName = process.env.MINIO_BUCKET_NAME!; + +// Function to upload a file to Minio +export async function uploadFile(objectName: string, fileBuffer: Buffer) { + + // Check if the bucket exists, if not, create it + const exists = await minioClient.bucketExists(bucketName); + if (!exists) { + throw new Error("Bucket not found") + } + + // Remove the file if it already exists + await minioClient.removeObject(bucketName, objectName); + + // Upload the file to the specified bucket and object + await minioClient.putObject(bucketName, objectName, fileBuffer); + // console.log('File uploaded successfully!'); + + // minioClient.presignedUrl('GET', bucketName, objectName, 24 * 60 * 60, function (err, presignedUrl) { + // if (err) return console.log(err) + // console.log(presignedUrl) + // }) + + return `https://${endPoint}/${bucketName}/${objectName}` +} + diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..0b393f3 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,9 @@ + +export type PaymentInfo = { + bankName: string; + branch: string; + accountNumber: string; + IFSCCode: string; + PANnumber: string; + GSTNumber: string; +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7ecc693..f108b93 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,10 @@ +import { HttpStatus } from "@nestjs/common"; +import { + PrismaClientKnownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime/library"; +import { get } from "lodash"; + export const validationOptions = { whitelist: true, transform: true, @@ -6,3 +13,38 @@ export const validationOptions = { enableImplicitConversion: true, }, }; + +export function getPrismaErrorStatusAndMessage(error: any): { + errorMessage: string | undefined; + statusCode: number; +} { + if ( + error instanceof PrismaClientKnownRequestError || + error instanceof PrismaClientValidationError + ) { + const errorCode = get(error, "code", "DEFAULT_ERROR_CODE"); + + const errorCodeMap: Record = { + P2000: HttpStatus.BAD_REQUEST, + P2002: HttpStatus.CONFLICT, + P2003: HttpStatus.CONFLICT, + P2025: HttpStatus.NOT_FOUND, + DEFAULT_ERROR_CODE: HttpStatus.INTERNAL_SERVER_ERROR, + }; + + const statusCode = errorCodeMap[errorCode]; + const errorMessage = error.message.split("\n").pop(); + + return { statusCode, errorMessage }; + } + + const statusCode = + error?.response?.data?.statusCode || + error?.status || + error?.response?.status || + HttpStatus.INTERNAL_SERVER_ERROR; + return { + statusCode, + errorMessage: error?.response?.data?.message || error?.message, + }; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8b9d137..7674d93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "esModuleInterop": true + "esModuleInterop": true, + "lib": ["ES2021.String"] } }