commit be82059172722cd48964d623c0de3196389e4d9d Author: mahyargdz Date: Sun Sep 14 15:15:03 2025 +0330 first diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9220633 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = LF +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + +# Matches the exact files either *.json or *.yml +[*.{json,yml},*.md] +trim_trailing_whitespace = false +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f1f7ec --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +NODE_ENV=development +PORT=4000 +#DB +# MONGO_URI=mongodb://root:pXuivdePAjReP4iJ7e0m5Lg4LxA@localhost:27017/shinan?authSource=admin&replicaSet=rs0&directConnection=true +REDIS_URI=redis://:@localhost:6379/0 +REDIS_HOST= +REDIS_PORT= +REDIS_PASSWORD= +#SMS +SMS_API_KEY= +SMS_NUMBER= +SMS_PATTERN_OTP= +SMS_PATTERN_USER_ORDER_PEND= +SMS_PATTERN_USER_ORDER_CREATE= +SMS_PATTERN_USER_ORDER_SHIPPED= +SMS_PATTERN_USER_ORDER_RETURNED= +SMS_PATTERN_SELLER_ORDER_CREATE= +SMS_PATTERN_SELLER_ORDER_CANCELLED= +#EMAIL +SMTP_EMAIL=info@shinan.ir +SMTP_HOST=smtp.c1.liara.email +SMTP_PORT= +SMTPS_PORT= +SMTP_USER= +SMTP_PASSWORD= +#JWT +JWT_SECRET= +JWT_EXPIRE_ACCESS=2 #minutes +JWT_EXPIRE_REFRESH=2 #days +#S3_BUCKET +BUCKET_NAME= +BUCKET_URL= +BUCKET_ACCESS_KEY= +BUCKET_SECRET_KEY= +#DEFAULT_ADMIN +DEFAULT_ADMIN_EMAIL= +DEFAULT_ADMIN_PASSWORD= +#PAYMENT_GATEWAY +IPG_TYPE=sandbox #sandbox or payments +SITE_URL=https://api.shinan.ir/payment +STORE_URL=https://shinan.com +SELLER_URL=https://seller.shinan.com + +ZARINPAL_MERCHANT_ID= +ASANPARDAKHT_MERCHANT_ID= +ASANPARDAKHT_MERCHANT_CONFIG_ID= +ASANPARDAKHT_USER= +ASANPARDAKHT_PASS= +#MAP +MAP_API_KEY= +NESHAN_API_KEY= + +LOGGER_LEVEL=info diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..10ab706 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +node_modules +dist +.eslintrc.js +.prettierrc.js +commitlint.config.ts \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..ec0a54a --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,98 @@ +module.exports = { + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "prettier", "import"], + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + ], + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + tsconfigRootDir: __dirname, + project: "./tsconfig.json", + }, + env: { + es6: true, + node: true, + }, + rules: { + // turn on errors for missing imports + "sort-imports": [ + "error", + { + ignoreCase: false, + ignoreDeclarationSort: true, + ignoreMemberSort: false, + memberSyntaxSortOrder: ["none", "all", "multiple", "single"], + allowSeparatedGroups: true, + }, + ], + "import/no-unresolved": "error", + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", ["sibling", "parent"], "index", "unknown"], + "newlines-between": "always", + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: "asc", + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ], + + // JavaScript Rules + "no-var": "error", + "prefer-const": "error", + "no-multi-spaces": "error", + "space-in-parens": "error", + "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], + semi: ["error", "always"], + + // Best Practices + "no-async-promise-executor": "warn", + "no-control-regex": "warn", + "no-empty": ["warn", { allowEmptyCatch: true }], + "no-extra-semi": "warn", + "no-prototype-builtins": "warn", + "no-regex-spaces": "warn", + "prefer-rest-params": "warn", + "prefer-spread": "warn", + + // TypeScript-Specific Rules + "@typescript-eslint/ban-ts-comment": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-loss-of-precision": "warn", + "@typescript-eslint/no-misused-new": "warn", + "@typescript-eslint/no-namespace": "warn", + "@typescript-eslint/no-this-alias": "warn", + "@typescript-eslint/no-unnecessary-type-constraint": "warn", + "@typescript-eslint/no-unsafe-declaration-merging": "warn", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/no-var-requires": "warn", + "@typescript-eslint/triple-slash-reference": "warn", + + // "@typescript-eslint/explicit-function-return-type": [ + // "warn", + // { + // allowExpressions: true, + // allowTypedFunctionExpressions: true, + // }, + // ], + // "@typescript-eslint/no-inferrable-types": "warn", + // "@typescript-eslint/no-non-null-assertion": "warn", + }, +}; diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..ca4a1ee --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,47 @@ +name: Build and Deploy +on: + push: + branches: + - main + +jobs: + build_and_deploy: + runs-on: ubuntu-latest + + env: + DANAK_SERVER: "https://captain.dev.danakcorp.com" + APP_TOKEN: 28cd7dee97fba06d2ca2e1de3f90ad6bd586b8b219ab7d13634d3096639637c4 + CAPROVER_APP_NAME: shop-api + GITHUB_TOKEN: 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 + + steps: + - name: Check out repositorys + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: zmihamid + password: ${{ env.GITHUB_TOKEN }} + + - name: Preset Image Name + run: echo "IMAGE_URL=$(echo ghcr.io/zmihamid/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Build and push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.IMAGE_URL }} + + - name: Install CapRover CLI + run: npm install -g caprover + + - name: deploy to server + run: | + caprover deploy -a $CAPROVER_APP_NAME -u $DANAK_SERVER --appToken $APP_TOKEN -i "$IMAGE_URL" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc0cec2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,147 @@ +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +.env + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test +.vscode + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +none/ +qodana.yaml +bunfig.toml +reflect-metadata-import.ts +.idea + + +.celp-ai + +page.html + +.DS_Store + +html diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..fd2bf70 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..bd73ce0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..161218b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "semi": true, + "arrowParens": "always", + "singleQuote": false, + "trailingComma": "all", + "printWidth": 140, + "endOfLine": "auto" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43c4a01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Stage 1: Build Stage +FROM node:22-alpine AS base + +RUN npm install -g corepack@latest +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Install tzdata to support timezone settings +RUN apk add --no-cache tzdata + +# Set the timezone to Asia/Tehran +RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone + + +FROM base AS deps +WORKDIR /temp-deps +COPY package*.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --loglevel info + + +FROM base AS builder +WORKDIR /build +COPY . ./ +COPY --from=deps /temp-deps/node_modules ./node_modules +RUN if [ -f package.json ] && grep -q '"build":' package.json; then pnpm run build; fi +RUN pnpm install --prod --frozen-lockfile --loglevel info + +FROM base AS runner +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +WORKDIR /app + +COPY . ./ +COPY --from=builder --chown=appuser:appgroup /build/ ./ + +RUN chown -R appuser:appgroup /app + +USER appuser + +ENV NODE_ENV=production +EXPOSE 4000 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..726268f --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +## shinan-api + +```bash + corepack enable pnpm + pnpm install +``` + +for start built version + +```bash + pnpm build + pnpm start +``` + +start development version + +```bash +pnpm dev +``` diff --git a/commitlint.config.ts b/commitlint.config.ts new file mode 100644 index 0000000..f65a807 --- /dev/null +++ b/commitlint.config.ts @@ -0,0 +1,8 @@ +export default { + extends: ["@commitlint/config-conventional"], + rules: { + "subject-case": [2, "always", ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case", "camel-case"]], + "type-enum": [2, "always", ["add", "build", "chore", "update", "docs", "feat", "fix", "refactor", "revert", "style", "test", "sample"]], + "scope-enum": [1, "always", ["common", "core", "app", "testing", "bug"]], + }, +}; diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..0401b21 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,10 @@ +{ + "watch": ["src", ".env"], + "ext": "ts,json", + "ignore": ["dist"], + "events": { + "start": "clear" + }, + "exec": "npm run build && node --trace-warnings dist/server.js", + "delay": 500 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6cbc319 --- /dev/null +++ b/package.json @@ -0,0 +1,104 @@ +{ + "name": "shinan_api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "prrebuild": "rimraf dist", + "build": "tsc -p tsconfig.build.json", + "typeCheck": "tsc --noEmit", + "start": "NODE_ENV=production node --trace-warnings dist/server.js", + "dev": "nodemon", + "tsx": "tsx watch --env-file=.env src/server.ts", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json)\"", + "seed": "node dist/db/seeders/index.js", + "seed:ts": "ts-node src/db/seeders/index.ts", + "test": "jest --config jest.config.js --coverage", + "prepare": "husky || true" + }, + "lint-staged": { + "**/*.ts": [ + "pnpm run format", + "pnpm run lint:fix", + "git add ." + ] + }, + "keywords": [], + "author": "Mahyar", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-s3": "^3.691.0", + "@faker-js/faker": "^7.6.0", + "@nestjs/mapped-types": "^2.0.6", + "ansi-colors": "^4.1.3", + "axios": "^1.7.7", + "bcrypt": "^5.1.1", + "bullmq": "^5.25.6", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "compression": "^1.7.5", + "cors": "^2.8.5", + "csv": "^6.3.11", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "express-basic-auth": "^1.2.1", + "express-rate-limit": "^7.4.1", + "handlebars": "^4.7.8", + "inversify": "^6.1.4", + "inversify-express-utils": "6.4.6", + "ioredis": "^5.4.1", + "jalali-moment": "^3.3.11", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.8.1", + "mongoose-sequence": "^6.0.1", + "morgan": "^1.10.0", + "multer": "1.4.5-lts.1", + "multer-s3": "^3.0.1", + "node-cache": "^5.1.2", + "nodemailer": "^6.9.16", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "slugify": "^1.6.6", + "socket.io": "^4.8.1", + "strip-ansi": "6.0.1", + "swagger-ui-express": "^5.0.1", + "winston": "^3.17.0" + }, + "devDependencies": { + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@types/bcrypt": "^5.0.2", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/mongoose-sequence": "^3.0.11", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", + "@types/multer-s3": "^3.0.3", + "@types/node": "^20.17.6", + "@types/nodemailer": "^6.4.16", + "@types/passport": "^1.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/swagger-ui-express": "^4.1.7", + "@typescript-eslint/eslint-plugin": "^8.14.0", + "@typescript-eslint/parser": "^8.14.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.1", + "husky": "^9.1.6", + "lint-staged": "^15.2.10", + "nodemon": "^3.1.7", + "prettier": "^3.3.3", + "rimraf": "^6.0.1", + "ts-node": "^10.9.2", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + }, + "packageManager": "pnpm@9.13.0+sha512.beb9e2a803db336c10c9af682b58ad7181ca0fbd0d4119f2b33d5f2582e96d6c0d93c85b23869295b765170fbdaa92890c0da6ada457415039769edf3c959efe" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..47dd938 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,7708 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.691.0 + version: 3.691.0 + '@faker-js/faker': + specifier: ^7.6.0 + version: 7.6.0 + '@nestjs/mapped-types': + specifier: ^2.0.6 + version: 2.0.6(@nestjs/common@10.4.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + ansi-colors: + specifier: ^4.1.3 + version: 4.1.3 + axios: + specifier: ^1.7.7 + version: 1.7.7 + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 + bullmq: + specifier: ^5.25.6 + version: 5.25.6 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + compression: + specifier: ^1.7.5 + version: 1.7.5 + cors: + specifier: ^2.8.5 + version: 2.8.5 + csv: + specifier: ^6.3.11 + version: 6.3.11 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + express: + specifier: ^4.21.1 + version: 4.21.1 + express-basic-auth: + specifier: ^1.2.1 + version: 1.2.1 + express-rate-limit: + specifier: ^7.4.1 + version: 7.4.1(express@4.21.1) + handlebars: + specifier: ^4.7.8 + version: 4.7.8 + inversify: + specifier: ^6.1.4 + version: 6.1.4(reflect-metadata@0.2.2) + inversify-express-utils: + specifier: 6.4.6 + version: 6.4.6(reflect-metadata@0.2.2) + ioredis: + specifier: ^5.4.1 + version: 5.4.1 + jalali-moment: + specifier: ^3.3.11 + version: 3.3.11 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + mongoose: + specifier: ^8.8.1 + version: 8.8.1(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3) + mongoose-sequence: + specifier: ^6.0.1 + version: 6.0.1(mongoose@8.8.1(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3)) + morgan: + specifier: ^1.10.0 + version: 1.10.0 + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 + multer-s3: + specifier: ^3.0.1 + version: 3.0.1(@aws-sdk/client-s3@3.691.0) + node-cache: + specifier: ^5.1.2 + version: 5.1.2 + nodemailer: + specifier: ^6.9.16 + version: 6.9.16 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + slugify: + specifier: ^1.6.6 + version: 1.6.6 + socket.io: + specifier: ^4.8.1 + version: 4.8.1 + strip-ansi: + specifier: 6.0.1 + version: 6.0.1 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@4.21.1) + winston: + specifier: ^3.17.0 + version: 3.17.0 + devDependencies: + '@commitlint/cli': + specifier: ^19.5.0 + version: 19.5.0(@types/node@20.17.6)(typescript@5.6.3) + '@commitlint/config-conventional': + specifier: ^19.5.0 + version: 19.5.0 + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + '@types/compression': + specifier: ^1.7.5 + version: 1.7.5 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.17 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.7 + '@types/mongoose-sequence': + specifier: ^3.0.11 + version: 3.0.11(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@types/morgan': + specifier: ^1.9.9 + version: 1.9.9 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 + '@types/multer-s3': + specifier: ^3.0.3 + version: 3.0.3 + '@types/node': + specifier: ^20.17.6 + version: 20.17.6 + '@types/nodemailer': + specifier: ^6.4.16 + version: 6.4.16 + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/swagger-ui-express': + specifier: ^4.1.7 + version: 4.1.7 + '@typescript-eslint/eslint-plugin': + specifier: ^8.14.0 + version: 8.14.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/parser': + specifier: ^8.14.0 + version: 8.14.0(eslint@8.57.1)(typescript@5.6.3) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) + husky: + specifier: ^9.1.6 + version: 9.1.6 + lint-staged: + specifier: ^15.2.10 + version: 15.2.10 + nodemon: + specifier: ^3.1.7 + version: 3.1.7 + prettier: + specifier: ^3.3.3 + version: 3.3.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.17.6)(typescript@5.6.3) + tsx: + specifier: ^4.19.2 + version: 4.19.2 + typescript: + specifier: ^5.6.3 + version: 5.6.3 + +packages: + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-cognito-identity@3.691.0': + resolution: {integrity: sha512-tyePoykDlsyTRBnDzOkj6ZmEqQKX8gI7cMsMr5o2T3OblkWdi1a3HYfWyMnjfKy43rOcuikokJ9zFlXYFMUq4A==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/client-s3@3.691.0': + resolution: {integrity: sha512-GrcFakf5sZDSFtQGIPzT/5CTl9rLCsua0+yrmz/zidCvd7HFiwPrmyLQSv+MwgEUqHb4unnqUMSo2HKfkV3AIQ==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/client-sso-oidc@3.691.0': + resolution: {integrity: sha512-3njUhD4buM1RfigU6IXZ18/R9V5mbqNrAftgDabnNn4/V4Qly32nz+KQONXT5x0GqPszGhp+0mmwuLai9DxSrQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-sts': ^3.691.0 + + '@aws-sdk/client-sso@3.691.0': + resolution: {integrity: sha512-bzp4ni6zGxwrlSWhG0MfOh57ORgzdUFlIc2JeQHLO9b6n0iNnG57ILHzo90sQxom6LfW1bXZrsKvYH3vAU8sdA==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/client-sts@3.691.0': + resolution: {integrity: sha512-Qmj2euPnmIni/eFSrc9LUkg52/2D487fTcKMwZh0ldHv4fD4ossuXX7AaDur8SD9Lc9EOxn/hXCsI644YnGwew==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/core@3.691.0': + resolution: {integrity: sha512-5hyCj6gX92fXRf1kyfIpJetjVx0NxHbNmcLcrMy6oXuGNIBeJkMp+ZC6uJo3PsIjyPgGQSC++EhjLxpWiF/wHg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.691.0': + resolution: {integrity: sha512-q1vHEPOOc7SyfCYfydeprCGOfJmGMfIRNSAaODU7gwOxktYKUcr16+OivmNI0OPHB1e9SNNbOCXLqmSygzOPBw==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-env@3.691.0': + resolution: {integrity: sha512-c4Ip7tSNxt5VANVyryl6XjfEUCbm7f+iCUEfEWEezywll4DjNZ1N0l7nNmX4dDbwRAB42XH3rk5fbqBe0lXT8g==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-http@3.691.0': + resolution: {integrity: sha512-RL2/d4DbUGeX8xKhXcwQvhAqd+WM3P87znSS5nEQA5pSwqeJsC3l2DCj+09yUM6I9n7nOppe5XephiiBpq190w==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-ini@3.691.0': + resolution: {integrity: sha512-NB5jbiBLAWD/oz2CHksKRHo+Q8KI8ljyZUDW091j7IDYEYZZ/c2jDkYWX7eGnJqKNZLxGtcc1B+yYJrE9xXnbQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-sts': ^3.691.0 + + '@aws-sdk/credential-provider-node@3.691.0': + resolution: {integrity: sha512-GjQvajKDz6nKWS1Cxdzz2Ecu9R8aojOhRIPAgnG62MG5BvlqDddanF6szcDVSYtlWx+cv2SZ6lDYjoHnDnideQ==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-process@3.691.0': + resolution: {integrity: sha512-tEoLkcxhF98aVHEyJ0n50rnNRewGUYYXszrNi8/sLh8enbDMWWByWReFPhNriE9oOdcrS5AKU7lCoY9i6zXQ3A==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-sso@3.691.0': + resolution: {integrity: sha512-CxEiF2LMesk93dG+fCglLyVS9m7rjkWAZFUSSbjW7YbJC0VDks83hQG8EsFv+Grl/kvFITEvU0NoiavI6hbDlw==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.691.0': + resolution: {integrity: sha512-54FgLnyWpSTlQ8/plZRFSXkI83wgPhJ4zqcX+n+K3BcGil4/Vsn/8+JQSY+6CA6JtDSqhpKAe54o+2DbDexsVg==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-sts': ^3.691.0 + + '@aws-sdk/credential-providers@3.691.0': + resolution: {integrity: sha512-zpp8nRcinjgtHpV8Wgnc2622/nl28m33mKULDMOLM010KK4IWZGGCy8HkKJkwLDa4hdSPYltPPsJ5/BvkZqr9w==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/lib-storage@3.691.0': + resolution: {integrity: sha512-sX1005ICZhXlWOI/H/EXU/LU6a57zlt7cLPjk+T/JlCldDVGccDTcZh3MFS3DB1FioTlXqf9oSSVoBvJQCNg6g==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.691.0 + + '@aws-sdk/middleware-bucket-endpoint@3.686.0': + resolution: {integrity: sha512-6qCoWI73/HDzQE745MHQUYz46cAQxHCgy1You8MZQX9vHAQwqBnkcsb2hGp7S6fnQY5bNsiZkMWVQ/LVd2MNjg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-expect-continue@3.686.0': + resolution: {integrity: sha512-5yYqIbyhLhH29vn4sHiTj7sU6GttvLMk3XwCmBXjo2k2j3zHqFUwh9RyFGF9VY6Z392Drf/E/cl+qOGypwULpg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.691.0': + resolution: {integrity: sha512-jBKW3hZ8YpxlAecwuvMDWvs5tqu2I3BubptKeVJiwrEhNR1Yy3gtsZ1RnxCfGEEdVLS4fxc5JRF/jxPFnTT00Q==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-host-header@3.686.0': + resolution: {integrity: sha512-+Yc6rO02z+yhFbHmRZGvEw1vmzf/ifS9a4aBjJGeVVU+ZxaUvnk+IUZWrj4YQopUQ+bSujmMUzJLXSkbDq7yuw==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-location-constraint@3.686.0': + resolution: {integrity: sha512-pCLeZzt5zUGY3NbW4J/5x3kaHyJEji4yqtoQcUlJmkoEInhSxJ0OE8sTxAfyL3nIOF4yr6L2xdaLCqYgQT8Aog==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-logger@3.686.0': + resolution: {integrity: sha512-cX43ODfA2+SPdX7VRxu6gXk4t4bdVJ9pkktbfnkE5t27OlwNfvSGGhnHrQL8xTOFeyQ+3T+oowf26gf1OI+vIg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.686.0': + resolution: {integrity: sha512-jF9hQ162xLgp9zZ/3w5RUNhmwVnXDBlABEUX8jCgzaFpaa742qR/KKtjjZQ6jMbQnP+8fOCSXFAVNMU+s6v81w==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.691.0': + resolution: {integrity: sha512-JYtpQNy9/M0qgihu7RY9vdrtuF+71H3U/BK7EqtskM/ioNL7twAAonCmXA2NXxYjS9bG+/3hw3xZkWSWfYvYFA==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-ssec@3.686.0': + resolution: {integrity: sha512-zJXml/CpVHFUdlGQqja87vNQ3rPB5SlDbfdwxlj1KBbjnRRwpBtxxmOlWRShg8lnVV6aIMGv95QmpIFy4ayqnQ==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/middleware-user-agent@3.691.0': + resolution: {integrity: sha512-d1ieFuOw7Lh4PQguSWceOgX0B4YkZOuYPRZhlAbwx/LQayoZ7LDh//0bbdDdgDgKyNxCTN5EjdoCh/MAPaKIjQ==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/region-config-resolver@3.686.0': + resolution: {integrity: sha512-6zXD3bSD8tcsMAVVwO1gO7rI1uy2fCD3czgawuPGPopeLiPpo6/3FoUWCQzk2nvEhj7p9Z4BbjwZGSlRkVrXTw==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.691.0': + resolution: {integrity: sha512-xCKaOoKJMTHxDWA82KTFOqAQUyGEKUqH+Est9aruR9alawbRx+qiLNt/+AhLrGT8IaFNycuD7P73V8yScJKE2g==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/token-providers@3.691.0': + resolution: {integrity: sha512-XtBnNUOzdezdC/7bFYAenrUQCZI5raHZ1F+7qWEbEDbshz4nR6v0MczVXkaPsSJ6mel0sQMhYs7b3Y/0yUkB6w==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-sso-oidc': ^3.691.0 + + '@aws-sdk/types@3.686.0': + resolution: {integrity: sha512-xFnrb3wxOoJcW2Xrh63ZgFo5buIu9DF7bOHnwoUxHdNpUXicUh0AHw85TjXxyxIAd0d1psY/DU7QHoNI3OswgQ==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/util-arn-parser@3.679.0': + resolution: {integrity: sha512-CwzEbU8R8rq9bqUFryO50RFBlkfufV9UfMArHPWlo+lmsC+NlSluHQALoj6Jkq3zf5ppn1CN0c1DDLrEqdQUXg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/util-endpoints@3.686.0': + resolution: {integrity: sha512-7msZE2oYl+6QYeeRBjlDgxQUhq/XRky3cXE0FqLFs2muLS7XSuQEXkpOXB3R782ygAP6JX0kmBxPTLurRTikZg==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/util-locate-window@3.679.0': + resolution: {integrity: sha512-zKTd48/ZWrCplkXpYDABI74rQlbR0DNHs8nH95htfSLj9/mWRSwaGptoxwcihaq/77vi/fl2X3y0a1Bo8bt7RA==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/util-user-agent-browser@3.686.0': + resolution: {integrity: sha512-YiQXeGYZegF1b7B2GOR61orhgv79qmI0z7+Agm3NXLO6hGfVV3kFUJbXnjtH1BgWo5hbZYW7HQ2omGb3dnb6Lg==} + + '@aws-sdk/util-user-agent-node@3.691.0': + resolution: {integrity: sha512-n+g337W2W/S3Ju47vBNs970477WsLidmdQp1jaxFaBYjSV8l7Tm4dZNMtrq4AEvS+2ErkLpm9BmTiREoWR38Ag==} + engines: {node: '>=16.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.686.0': + resolution: {integrity: sha512-k0z5b5dkYSuOHY0AOZ4iyjcGBeVL9lWsQNF4+c+1oK3OW4fRWl/bNa1soMRMpangsHPzgyn/QkzuDbl7qR4qrw==} + engines: {node: '>=16.0.0'} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@19.5.0': + resolution: {integrity: sha512-gaGqSliGwB86MDmAAKAtV9SV1SHdmN8pnGq4EJU4+hLisQ7IFfx4jvU4s+pk6tl0+9bv6yT+CaZkufOinkSJIQ==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.5.0': + resolution: {integrity: sha512-OBhdtJyHNPryZKg0fFpZNOBM1ZDbntMvqMuSmpfyP86XSfwzGw4CaoYRG4RutUPg0BTK07VMRIkNJT6wi2zthg==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.5.0': + resolution: {integrity: sha512-CHtj92H5rdhKt17RmgALhfQt95VayrUo2tSqY9g2w+laAXyk7K/Ef6uPm9tn5qSIwSmrLjKaXK9eiNuxmQrDBw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@19.5.0': + resolution: {integrity: sha512-Kv0pYZeMrdg48bHFEU5KKcccRfKmISSm9MvgIgkpI6m+ohFTB55qZlBW6eYqh/XDfRuIO0x4zSmvBjmOwWTwkg==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.5.0': + resolution: {integrity: sha512-aqyGgytXhl2ejlk+/rfgtwpPexYyri4t8/n4ku6rRJoRhGZpLFMqrZ+YaubeGysCP6oz4mMA34YSTaSOKEeNrg==} + engines: {node: '>=v18'} + + '@commitlint/format@19.5.0': + resolution: {integrity: sha512-yNy088miE52stCI3dhG/vvxFo9e4jFkU1Mj3xECfzp/bIS/JUay4491huAlVcffOoMK1cd296q0W92NlER6r3A==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.5.0': + resolution: {integrity: sha512-0XQ7Llsf9iL/ANtwyZ6G0NGp5Y3EQ8eDQSxv/SRcfJ0awlBY4tHFAvwWbw66FVUaWICH7iE5en+FD9TQsokZ5w==} + engines: {node: '>=v18'} + + '@commitlint/lint@19.5.0': + resolution: {integrity: sha512-cAAQwJcRtiBxQWO0eprrAbOurtJz8U6MgYqLz+p9kLElirzSCc0vGMcyCaA1O7AqBuxo11l1XsY3FhOFowLAAg==} + engines: {node: '>=v18'} + + '@commitlint/load@19.5.0': + resolution: {integrity: sha512-INOUhkL/qaKqwcTUvCE8iIUf5XHsEPCLY9looJ/ipzi7jtGhgmtH7OOFiNvwYgH7mA8osUWOUDV8t4E2HAi4xA==} + engines: {node: '>=v18'} + + '@commitlint/message@19.5.0': + resolution: {integrity: sha512-R7AM4YnbxN1Joj1tMfCyBryOC5aNJBdxadTZkuqtWi3Xj0kMdutq16XQwuoGbIzL2Pk62TALV1fZDCv36+JhTQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@19.5.0': + resolution: {integrity: sha512-cZ/IxfAlfWYhAQV0TwcbdR1Oc0/r0Ik1GEessDJ3Lbuma/MRO8FRQX76eurcXtmhJC//rj52ZSZuXUg0oIX0Fw==} + engines: {node: '>=v18'} + + '@commitlint/read@19.5.0': + resolution: {integrity: sha512-TjS3HLPsLsxFPQj6jou8/CZFAmOP2y+6V4PGYt3ihbQKTY1Jnv0QG28WRKl/d1ha6zLODPZqsxLEov52dhR9BQ==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.5.0': + resolution: {integrity: sha512-CU/GscZhCUsJwcKTJS9Ndh3AKGZTNFIOoQB2n8CmFnizE0VnEuJoum+COW+C1lNABEeqk6ssfc1Kkalm4bDklA==} + engines: {node: '>=v18'} + + '@commitlint/rules@19.5.0': + resolution: {integrity: sha512-hDW5TPyf/h1/EufSHEKSp6Hs+YVsDMHazfJ2azIk9tHPXS6UqSz1dIRs1gpqS3eMXgtkT7JH6TW4IShdqOwhAw==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.5.0': + resolution: {integrity: sha512-R772oj3NHPkodOSRZ9bBVNq224DOxQtNef5Pl8l2M8ZnkkzQfeSTr4uxawV2Sd3ui05dUVzvLNnzenDBO1KBeQ==} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.5.0': + resolution: {integrity: sha512-IP1YLmGAk0yWrImPRRc578I3dDUI5A2UBJx9FbSOjxe9sTlzFiwVJ+zeMLgAtHMtGZsC8LUnzmW1qRemkFU4ng==} + engines: {node: '>=v18'} + + '@commitlint/types@19.5.0': + resolution: {integrity: sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==} + engines: {node: '>=v18'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@dabh/diagnostics@2.0.3': + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + + '@esbuild/aix-ppc64@0.23.1': + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.23.1': + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.23.1': + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.23.1': + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.23.1': + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.23.1': + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.23.1': + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.23.1': + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.23.1': + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.23.1': + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.23.1': + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.23.1': + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.23.1': + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.23.1': + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.23.1': + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.23.1': + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.23.1': + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.23.1': + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.23.1': + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.23.1': + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.23.1': + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.23.1': + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.23.1': + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.23.1': + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@faker-js/faker@7.6.0': + resolution: {integrity: sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==} + engines: {node: '>=14.0.0', npm: '>=6.0.0'} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@inversifyjs/common@1.3.3': + resolution: {integrity: sha512-ZH0wrgaJwIo3s9gMCDM2wZoxqrJ6gB97jWXncROfYdqZJv8f3EkqT57faZqN5OTeHWgtziQ6F6g3L8rCvGceCw==} + + '@inversifyjs/core@1.3.4': + resolution: {integrity: sha512-gCCmA4BdbHEFwvVZ2elWgHuXZWk6AOu/1frxsS+2fWhjEk2c/IhtypLo5ytSUie1BCiT6i9qnEo4bruBomQsAA==} + + '@inversifyjs/reflect-metadata-utils@0.2.3': + resolution: {integrity: sha512-d3D0o9TeSlvaGM2I24wcNw/Aj3rc4OYvHXOKDC09YEph5fMMiKd6fq1VTQd9tOkDNWvVbw+cnt45Wy9P/t5Lvw==} + peerDependencies: + reflect-metadata: 0.2.2 + + '@ioredis/commands@1.2.0': + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@mongodb-js/saslprep@1.1.9': + resolution: {integrity: sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@nestjs/common@10.4.8': + resolution: {integrity: sha512-PVor9dxihg3F2LMnVNkQu42vUmea2+qukkWXUSumtVKDsBo7X7jnZWXtF5bvNTcYK7IYL4/MM4awNfJVJcJpFg==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/mapped-types@2.0.6': + resolution: {integrity: sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@smithy/abort-controller@3.1.6': + resolution: {integrity: sha512-0XuhuHQlEqbNQZp7QxxrFTdVWdwxch4vjxYgfInF91hZFkPxf9QDrdQka0KfxFMPqLNzSw0b95uGTrLliQUavQ==} + engines: {node: '>=16.0.0'} + + '@smithy/chunked-blob-reader-native@3.0.1': + resolution: {integrity: sha512-VEYtPvh5rs/xlyqpm5NRnfYLZn+q0SRPELbvBV+C/G7IQ+ouTuo+NKKa3ShG5OaFR8NYVMXls9hPYLTvIKKDrQ==} + + '@smithy/chunked-blob-reader@4.0.0': + resolution: {integrity: sha512-jSqRnZvkT4egkq/7b6/QRCNXmmYVcHwnJldqJ3IhVpQE2atObVJ137xmGeuGFhjFUr8gCEVAOKwSY79OvpbDaQ==} + + '@smithy/config-resolver@3.0.10': + resolution: {integrity: sha512-Uh0Sz9gdUuz538nvkPiyv1DZRX9+D15EKDtnQP5rYVAzM/dnYk3P8cg73jcxyOitPgT3mE3OVj7ky7sibzHWkw==} + engines: {node: '>=16.0.0'} + + '@smithy/core@2.5.1': + resolution: {integrity: sha512-DujtuDA7BGEKExJ05W5OdxCoyekcKT3Rhg1ZGeiUWaz2BJIWXjZmsG/DIP4W48GHno7AQwRsaCb8NcBgH3QZpg==} + engines: {node: '>=16.0.0'} + + '@smithy/credential-provider-imds@3.2.5': + resolution: {integrity: sha512-4FTQGAsuwqTzVMmiRVTn0RR9GrbRfkP0wfu/tXWVHd2LgNpTY0uglQpIScXK4NaEyXbB3JmZt8gfVqO50lP8wg==} + engines: {node: '>=16.0.0'} + + '@smithy/eventstream-codec@3.1.7': + resolution: {integrity: sha512-kVSXScIiRN7q+s1x7BrQtZ1Aa9hvvP9FeCqCdBxv37GimIHgBCOnZ5Ip80HLt0DhnAKpiobFdGqTFgbaJNrazA==} + + '@smithy/eventstream-serde-browser@3.0.11': + resolution: {integrity: sha512-Pd1Wnq3CQ/v2SxRifDUihvpXzirJYbbtXfEnnLV/z0OGCTx/btVX74P86IgrZkjOydOASBGXdPpupYQI+iO/6A==} + engines: {node: '>=16.0.0'} + + '@smithy/eventstream-serde-config-resolver@3.0.8': + resolution: {integrity: sha512-zkFIG2i1BLbfoGQnf1qEeMqX0h5qAznzaZmMVNnvPZz9J5AWBPkOMckZWPedGUPcVITacwIdQXoPcdIQq5FRcg==} + engines: {node: '>=16.0.0'} + + '@smithy/eventstream-serde-node@3.0.10': + resolution: {integrity: sha512-hjpU1tIsJ9qpcoZq9zGHBJPBOeBGYt+n8vfhDwnITPhEre6APrvqq/y3XMDEGUT2cWQ4ramNqBPRbx3qn55rhw==} + engines: {node: '>=16.0.0'} + + '@smithy/eventstream-serde-universal@3.0.10': + resolution: {integrity: sha512-ewG1GHbbqsFZ4asaq40KmxCmXO+AFSM1b+DcO2C03dyJj/ZH71CiTg853FSE/3SHK9q3jiYQIFjlGSwfxQ9kww==} + engines: {node: '>=16.0.0'} + + '@smithy/fetch-http-handler@4.0.0': + resolution: {integrity: sha512-MLb1f5tbBO2X6K4lMEKJvxeLooyg7guq48C2zKr4qM7F2Gpkz4dc+hdSgu77pCJ76jVqFBjZczHYAs6dp15N+g==} + + '@smithy/hash-blob-browser@3.1.7': + resolution: {integrity: sha512-4yNlxVNJifPM5ThaA5HKnHkn7JhctFUHvcaz6YXxHlYOSIrzI6VKQPTN8Gs1iN5nqq9iFcwIR9THqchUCouIfg==} + + '@smithy/hash-node@3.0.8': + resolution: {integrity: sha512-tlNQYbfpWXHimHqrvgo14DrMAgUBua/cNoz9fMYcDmYej7MAmUcjav/QKQbFc3NrcPxeJ7QClER4tWZmfwoPng==} + engines: {node: '>=16.0.0'} + + '@smithy/hash-stream-node@3.1.7': + resolution: {integrity: sha512-xMAsvJ3hLG63lsBVi1Hl6BBSfhd8/Qnp8fC06kjOpJvyyCEXdwHITa5Kvdsk6gaAXLhbZMhQMIGvgUbfnJDP6Q==} + engines: {node: '>=16.0.0'} + + '@smithy/invalid-dependency@3.0.8': + resolution: {integrity: sha512-7Qynk6NWtTQhnGTTZwks++nJhQ1O54Mzi7fz4PqZOiYXb4Z1Flpb2yRvdALoggTS8xjtohWUM+RygOtB30YL3Q==} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@3.0.0': + resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==} + engines: {node: '>=16.0.0'} + + '@smithy/md5-js@3.0.8': + resolution: {integrity: sha512-LwApfTK0OJ/tCyNUXqnWCKoE2b4rDSr4BJlDAVCkiWYeHESr+y+d5zlAanuLW6fnitVJRD/7d9/kN/ZM9Su4mA==} + + '@smithy/middleware-content-length@3.0.10': + resolution: {integrity: sha512-T4dIdCs1d/+/qMpwhJ1DzOhxCZjZHbHazEPJWdB4GDi2HjIZllVzeBEcdJUN0fomV8DURsgOyrbEUzg3vzTaOg==} + engines: {node: '>=16.0.0'} + + '@smithy/middleware-endpoint@3.2.1': + resolution: {integrity: sha512-wWO3xYmFm6WRW8VsEJ5oU6h7aosFXfszlz3Dj176pTij6o21oZnzkCLzShfmRaaCHDkBXWBdO0c4sQAvLFP6zA==} + engines: {node: '>=16.0.0'} + + '@smithy/middleware-retry@3.0.25': + resolution: {integrity: sha512-m1F70cPaMBML4HiTgCw5I+jFNtjgz5z5UdGnUbG37vw6kh4UvizFYjqJGHvicfgKMkDL6mXwyPp5mhZg02g5sg==} + engines: {node: '>=16.0.0'} + + '@smithy/middleware-serde@3.0.8': + resolution: {integrity: sha512-Xg2jK9Wc/1g/MBMP/EUn2DLspN8LNt+GMe7cgF+Ty3vl+Zvu+VeZU5nmhveU+H8pxyTsjrAkci8NqY6OuvZnjA==} + engines: {node: '>=16.0.0'} + + '@smithy/middleware-stack@3.0.8': + resolution: {integrity: sha512-d7ZuwvYgp1+3682Nx0MD3D/HtkmZd49N3JUndYWQXfRZrYEnCWYc8BHcNmVsPAp9gKvlurdg/mubE6b/rPS9MA==} + engines: {node: '>=16.0.0'} + + '@smithy/node-config-provider@3.1.9': + resolution: {integrity: sha512-qRHoah49QJ71eemjuS/WhUXB+mpNtwHRWQr77J/m40ewBVVwvo52kYAmb7iuaECgGTTcYxHS4Wmewfwy++ueew==} + engines: {node: '>=16.0.0'} + + '@smithy/node-http-handler@3.2.5': + resolution: {integrity: sha512-PkOwPNeKdvX/jCpn0A8n9/TyoxjGZB8WVoJmm9YzsnAgggTj4CrjpRHlTQw7dlLZ320n1mY1y+nTRUDViKi/3w==} + engines: {node: '>=16.0.0'} + + '@smithy/property-provider@3.1.8': + resolution: {integrity: sha512-ukNUyo6rHmusG64lmkjFeXemwYuKge1BJ8CtpVKmrxQxc6rhUX0vebcptFA9MmrGsnLhwnnqeH83VTU9hwOpjA==} + engines: {node: '>=16.0.0'} + + '@smithy/protocol-http@4.1.5': + resolution: {integrity: sha512-hsjtwpIemmCkm3ZV5fd/T0bPIugW1gJXwZ/hpuVubt2hEUApIoUTrf6qIdh9MAWlw0vjMrA1ztJLAwtNaZogvg==} + engines: {node: '>=16.0.0'} + + '@smithy/querystring-builder@3.0.8': + resolution: {integrity: sha512-btYxGVqFUARbUrN6VhL9c3dnSviIwBYD9Rz1jHuN1hgh28Fpv2xjU1HeCeDJX68xctz7r4l1PBnFhGg1WBBPuA==} + engines: {node: '>=16.0.0'} + + '@smithy/querystring-parser@3.0.8': + resolution: {integrity: sha512-BtEk3FG7Ks64GAbt+JnKqwuobJNX8VmFLBsKIwWr1D60T426fGrV2L3YS5siOcUhhp6/Y6yhBw1PSPxA5p7qGg==} + engines: {node: '>=16.0.0'} + + '@smithy/service-error-classification@3.0.8': + resolution: {integrity: sha512-uEC/kCCFto83bz5ZzapcrgGqHOh/0r69sZ2ZuHlgoD5kYgXJEThCoTuw/y1Ub3cE7aaKdznb+jD9xRPIfIwD7g==} + engines: {node: '>=16.0.0'} + + '@smithy/shared-ini-file-loader@3.1.9': + resolution: {integrity: sha512-/+OsJRNtoRbtsX0UpSgWVxFZLsJHo/4sTr+kBg/J78sr7iC+tHeOvOJrS5hCpVQ6sWBbhWLp1UNiuMyZhE6pmA==} + engines: {node: '>=16.0.0'} + + '@smithy/signature-v4@4.2.1': + resolution: {integrity: sha512-NsV1jF4EvmO5wqmaSzlnTVetemBS3FZHdyc5CExbDljcyJCEEkJr8ANu2JvtNbVg/9MvKAWV44kTrGS+Pi4INg==} + engines: {node: '>=16.0.0'} + + '@smithy/smithy-client@3.4.2': + resolution: {integrity: sha512-dxw1BDxJiY9/zI3cBqfVrInij6ShjpV4fmGHesGZZUiP9OSE/EVfdwdRz0PgvkEvrZHpsj2htRaHJfftE8giBA==} + engines: {node: '>=16.0.0'} + + '@smithy/types@3.6.0': + resolution: {integrity: sha512-8VXK/KzOHefoC65yRgCn5vG1cysPJjHnOVt9d0ybFQSmJgQj152vMn4EkYhGuaOmnnZvCPav/KnYyE6/KsNZ2w==} + engines: {node: '>=16.0.0'} + + '@smithy/url-parser@3.0.8': + resolution: {integrity: sha512-4FdOhwpTW7jtSFWm7SpfLGKIBC9ZaTKG5nBF0wK24aoQKQyDIKUw3+KFWCQ9maMzrgTJIuOvOnsV2lLGW5XjTg==} + + '@smithy/util-base64@3.0.0': + resolution: {integrity: sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==} + engines: {node: '>=16.0.0'} + + '@smithy/util-body-length-browser@3.0.0': + resolution: {integrity: sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==} + + '@smithy/util-body-length-node@3.0.0': + resolution: {integrity: sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@3.0.0': + resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-config-provider@3.0.0': + resolution: {integrity: sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==} + engines: {node: '>=16.0.0'} + + '@smithy/util-defaults-mode-browser@3.0.25': + resolution: {integrity: sha512-fRw7zymjIDt6XxIsLwfJfYUfbGoO9CmCJk6rjJ/X5cd20+d2Is7xjU5Kt/AiDt6hX8DAf5dztmfP5O82gR9emA==} + engines: {node: '>= 10.0.0'} + + '@smithy/util-defaults-mode-node@3.0.25': + resolution: {integrity: sha512-H3BSZdBDiVZGzt8TG51Pd2FvFO0PAx/A0mJ0EH8a13KJ6iUCdYnw/Dk/MdC1kTd0eUuUGisDFaxXVXo4HHFL1g==} + engines: {node: '>= 10.0.0'} + + '@smithy/util-endpoints@2.1.4': + resolution: {integrity: sha512-kPt8j4emm7rdMWQyL0F89o92q10gvCUa6sBkBtDJ7nV2+P7wpXczzOfoDJ49CKXe5CCqb8dc1W+ZdLlrKzSAnQ==} + engines: {node: '>=16.0.0'} + + '@smithy/util-hex-encoding@3.0.0': + resolution: {integrity: sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==} + engines: {node: '>=16.0.0'} + + '@smithy/util-middleware@3.0.8': + resolution: {integrity: sha512-p7iYAPaQjoeM+AKABpYWeDdtwQNxasr4aXQEA/OmbOaug9V0odRVDy3Wx4ci8soljE/JXQo+abV0qZpW8NX0yA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-retry@3.0.8': + resolution: {integrity: sha512-TCEhLnY581YJ+g1x0hapPz13JFqzmh/pMWL2KEFASC51qCfw3+Y47MrTmea4bUE5vsdxQ4F6/KFbUeSz22Q1ow==} + engines: {node: '>=16.0.0'} + + '@smithy/util-stream@3.2.1': + resolution: {integrity: sha512-R3ufuzJRxSJbE58K9AEnL/uSZyVdHzud9wLS8tIbXclxKzoe09CRohj2xV8wpx5tj7ZbiJaKYcutMm1eYgz/0A==} + engines: {node: '>=16.0.0'} + + '@smithy/util-uri-escape@3.0.0': + resolution: {integrity: sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==} + engines: {node: '>=16.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@3.0.0': + resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==} + engines: {node: '>=16.0.0'} + + '@smithy/util-waiter@3.1.7': + resolution: {integrity: sha512-d5yGlQtmN/z5eoTtIYgkvOw27US2Ous4VycnXatyoImIF9tzlcpnKqQ/V7qhvJmb2p6xZne1NopCLakdTnkBBQ==} + engines: {node: '>=16.0.0'} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/conventional-commits-parser@5.0.0': + resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} + + '@types/cookie@0.4.1': + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + + '@types/cors@2.8.17': + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/jsonwebtoken@9.0.7': + resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/mongoose-sequence@3.0.11': + resolution: {integrity: sha512-5O7YNjLACoVri5ikrUFNpT+PY8f+EKYSeJLh3CKjczX5S7h5xvVL9VpJvQj5nQhawyaKu+peqZdMcfj5gOIFnA==} + + '@types/morgan@1.9.9': + resolution: {integrity: sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==} + + '@types/multer-s3@3.0.3': + resolution: {integrity: sha512-VgWygI9UwyS7loLithUUi0qAMIDWdNrERS2Sb06UuPYiLzKuIFn2NgL7satyl4v8sh/LLoU7DiPanvbQaRg9Yg==} + + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} + + '@types/node@20.17.6': + resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} + + '@types/nodemailer@6.4.16': + resolution: {integrity: sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.9.17': + resolution: {integrity: sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + + '@types/swagger-ui-express@4.1.7': + resolution: {integrity: sha512-ovLM9dNincXkzH4YwyYpll75vhzPBlWx6La89wwvYH7mHjVpf0X0K/vR/aUM7SRxmr5tt9z7E5XJcjQ46q+S3g==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/validator@13.12.2': + resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==} + + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + + '@types/whatwg-url@8.2.2': + resolution: {integrity: sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==} + + '@typescript-eslint/eslint-plugin@8.14.0': + resolution: {integrity: sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@8.14.0': + resolution: {integrity: sha512-2p82Yn9juUJq0XynBXtFCyrBDb6/dJombnz6vbo6mgQEtWHfvHbQuEa9kAOVIt1c9YFwi7H6WxtPj1kg+80+RA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@8.14.0': + resolution: {integrity: sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.14.0': + resolution: {integrity: sha512-Xcz9qOtZuGusVOH5Uk07NGs39wrKkf3AxlkK79RBK6aJC1l03CobXjJbwBPSidetAOV+5rEVuiT1VSBUOAsanQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@8.14.0': + resolution: {integrity: sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.14.0': + resolution: {integrity: sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.14.0': + resolution: {integrity: sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@typescript-eslint/visitor-keys@8.14.0': + resolution: {integrity: sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bowser@2.11.0: + resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bson@4.7.2: + resolution: {integrity: sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==} + engines: {node: '>=6.9.0'} + + bson@6.9.0: + resolution: {integrity: sha512-X9hJeyeM0//Fus+0pc5dSUMhhrrmWwQUtdavaQeF3Ta6m69matZkGWV/MrBcnwUeLC8W9kwwc2hfkZgUuCX3Ig==} + engines: {node: '>=16.20.1'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bullmq@5.25.6: + resolution: {integrity: sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.1: + resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + colorspace@1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@5.1.0: + resolution: {integrity: sha512-7PtBB+6FdsOvZyJtlF3hEPpACq7RQX6BVGsgC7/lfVXnKMvNCu/XY3ykreqG5w/rBNdu2z8LCIKoF3kpHHdHlA==} + engines: {node: '>=v16'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=8.2' + typescript: '>=4' + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cross-spawn@7.0.5: + resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} + engines: {node: '>= 8'} + + csv-generate@4.4.2: + resolution: {integrity: sha512-W6nVsf+rz0J3yo9FOjeer7tmzBJKaTTxf7K0uw6GZgRocZYPVpuSWWa5/aoWWrjQZj4/oNIKTYapOM7hiNjVMA==} + + csv-parse@5.6.0: + resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} + + csv-stringify@6.5.2: + resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==} + + csv@6.3.11: + resolution: {integrity: sha512-a8bhT76Q546jOElHcTrkzWY7Py925mfLO/jqquseH61ThOebYwOjLbWHBqdRB4K1VpU36sTyIei6Jwj7QdEZ7g==} + engines: {node: '>= 0.1.90'} + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.2: + resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} + engines: {node: '>=10.2.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-abstract@1.23.4: + resolution: {integrity: sha512-HR1gxH5OaiN7XH7uiWH0RLw0RcFySiSoW1ctxmD1ahTw3uGBtkmm/ng0tDU1OtYx5OK6EOL5Y6O21cDflG3Jcg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + + esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.12.0: + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.31.0: + resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + express-basic-auth@1.2.1: + resolution: {integrity: sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==} + + express-rate-limit@7.4.1: + resolution: {integrity: sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==} + engines: {node: '>= 16'} + peerDependencies: + express: 4 || 5 || ^5.0.0-beta.1 + + express@4.21.1: + resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} + engines: {node: '>= 0.10.0'} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + + fast-xml-parser@4.4.1: + resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} + hasBin: true + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.8.1: + resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-comment-regex@1.1.2: + resolution: {integrity: sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.6: + resolution: {integrity: sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + + inversify-express-utils@6.4.6: + resolution: {integrity: sha512-IWvWS8pcg2jwMxrSH3o12BFdpZbO1DM/0U/f5GyCV07sbsNVNaHjgBHRpHTvOHhyxakxoXQLknXF47RI5JqPHA==} + + inversify@6.1.4: + resolution: {integrity: sha512-PbxrZH/gTa1fpPEEGAjJQzK8tKMIp5gRg6EFNJlCtzUcycuNdmhv3uk5P8Itm/RIjgHJO16oQRLo9IHzQN51bA==} + + ioredis@5.4.1: + resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} + engines: {node: '>=12.22.0'} + + ip-address@9.0.5: + resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + + jalali-moment@3.3.11: + resolution: {integrity: sha512-tdSaRs9cjWjOIaWhcsGFZMhZQhfgok5J0TwqFpBIZPudZxxa6yjUPoLCOwuvbAtRpiZn7k/mvazAJh+vEN5suw==} + hasBin: true + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + kareem@2.5.1: + resolution: {integrity: sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==} + engines: {node: '>=12.0.0'} + + kareem@2.6.3: + resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} + engines: {node: '>=12.0.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.11.14: + resolution: {integrity: sha512-sexvAfwcW1Lqws4zFp8heAtAEXbEDnvkYCEGzvOoMgZR7JhXo/IkE9MkkGACgBed5fWqh3ShBGnJBdDnU9N8EQ==} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@15.2.10: + resolution: {integrity: sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.2.5: + resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} + engines: {node: '>=18.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.53.0: + resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mongodb-connection-string-url@2.6.0: + resolution: {integrity: sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==} + + mongodb-connection-string-url@3.0.1: + resolution: {integrity: sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==} + + mongodb@4.17.2: + resolution: {integrity: sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==} + engines: {node: '>=12.9.0'} + + mongodb@6.10.0: + resolution: {integrity: sha512-gP9vduuYWb9ZkDM546M+MP2qKVk5ZG2wPF63OvSRuUbqCR+11ZCAE1mOfllhlAG0wcoJY5yDL/rV3OmYEwXIzg==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.2.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose-sequence@6.0.1: + resolution: {integrity: sha512-uXnLCW9pu2V49Xw8BmdXdeRugd2mv+ntu3nT2Bbm33pNRmmvHE2GKA+8BASKoQt960McLX4VL78wkb492f6MoQ==} + peerDependencies: + mongoose: '>=5' + + mongoose@6.13.3: + resolution: {integrity: sha512-TCB/k6ZmkLZGZY/HJ78Ep45Za63591ZuZu5+HCISTe+0lsqbDeomqwezh+Ir7gMLa0wJwIy6CNkl5dxhCXTu9Q==} + engines: {node: '>=12.0.0'} + + mongoose@8.8.1: + resolution: {integrity: sha512-l7DgeY1szT98+EKU8GYnga5WnyatAu+kOQ2VlVX1Mxif6A0Umt0YkSiksCiyGxzx8SPhGe9a53ND1GD4yVDrPA==} + engines: {node: '>=16.20.1'} + + morgan@1.10.0: + resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} + engines: {node: '>= 0.8.0'} + + mpath@0.9.0: + resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} + engines: {node: '>=4.0.0'} + + mquery@4.0.3: + resolution: {integrity: sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==} + engines: {node: '>=12.0.0'} + + mquery@5.0.0: + resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} + engines: {node: '>=14.0.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.2: + resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + + multer-s3@3.0.1: + resolution: {integrity: sha512-BFwSO80a5EW4GJRBdUuSHblz2jhVSAze33ZbnGpcfEicoT0iRolx4kWR+AJV07THFRCQ78g+kelKFdjkCCaXeQ==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.0.0 + + multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + nodemailer@6.9.16: + resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==} + engines: {node: '>=6.0.0'} + + nodemon@3.1.7: + resolution: {integrity: sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==} + engines: {node: '>=10'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.3: + resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + + path-to-regexp@0.1.10: + resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + regexp.prototype.flags@1.5.3: + resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + sift@16.0.1: + resolution: {integrity: sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==} + + sift@17.1.3: + resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + + socks@2.8.3: + resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-transform@3.3.3: + resolution: {integrity: sha512-dALXrXe+uq4aO5oStdHKlfCM/b3NBdouigvxVPxCdrMRAU6oHh3KNss20VbTPQNQmjAHzZGKGe66vgwegFEIog==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + swagger-ui-dist@5.18.2: + resolution: {integrity: sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + engines: {node: ^14.18.0 || >=16.0.0} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyexec@0.3.1: + resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@1.4.0: + resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.19.2: + resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + whatwg-url@13.0.0: + resolution: {integrity: sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==} + engines: {node: '>=16'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.17.0: + resolution: {integrity: sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==} + engines: {node: '>= 12.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.5.1: + resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + +snapshots: + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.686.0 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.686.0 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-locate-window': 3.679.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-locate-window': 3.679.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.686.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-cognito-identity@3.691.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/client-sso-oidc': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-node': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/middleware-host-header': 3.686.0 + '@aws-sdk/middleware-logger': 3.686.0 + '@aws-sdk/middleware-recursion-detection': 3.686.0 + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/region-config-resolver': 3.686.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@aws-sdk/util-user-agent-browser': 3.686.0 + '@aws-sdk/util-user-agent-node': 3.691.0 + '@smithy/config-resolver': 3.0.10 + '@smithy/core': 2.5.1 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/hash-node': 3.0.8 + '@smithy/invalid-dependency': 3.0.8 + '@smithy/middleware-content-length': 3.0.10 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-retry': 3.0.25 + '@smithy/middleware-serde': 3.0.8 + '@smithy/middleware-stack': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/node-http-handler': 3.2.5 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-base64': 3.0.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-body-length-node': 3.0.0 + '@smithy/util-defaults-mode-browser': 3.0.25 + '@smithy/util-defaults-mode-node': 3.0.25 + '@smithy/util-endpoints': 2.1.4 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + optional: true + + '@aws-sdk/client-s3@3.691.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/client-sso-oidc': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-node': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/middleware-bucket-endpoint': 3.686.0 + '@aws-sdk/middleware-expect-continue': 3.686.0 + '@aws-sdk/middleware-flexible-checksums': 3.691.0 + '@aws-sdk/middleware-host-header': 3.686.0 + '@aws-sdk/middleware-location-constraint': 3.686.0 + '@aws-sdk/middleware-logger': 3.686.0 + '@aws-sdk/middleware-recursion-detection': 3.686.0 + '@aws-sdk/middleware-sdk-s3': 3.691.0 + '@aws-sdk/middleware-ssec': 3.686.0 + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/region-config-resolver': 3.686.0 + '@aws-sdk/signature-v4-multi-region': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@aws-sdk/util-user-agent-browser': 3.686.0 + '@aws-sdk/util-user-agent-node': 3.691.0 + '@aws-sdk/xml-builder': 3.686.0 + '@smithy/config-resolver': 3.0.10 + '@smithy/core': 2.5.1 + '@smithy/eventstream-serde-browser': 3.0.11 + '@smithy/eventstream-serde-config-resolver': 3.0.8 + '@smithy/eventstream-serde-node': 3.0.10 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/hash-blob-browser': 3.1.7 + '@smithy/hash-node': 3.0.8 + '@smithy/hash-stream-node': 3.1.7 + '@smithy/invalid-dependency': 3.0.8 + '@smithy/md5-js': 3.0.8 + '@smithy/middleware-content-length': 3.0.10 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-retry': 3.0.25 + '@smithy/middleware-serde': 3.0.8 + '@smithy/middleware-stack': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/node-http-handler': 3.2.5 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-base64': 3.0.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-body-length-node': 3.0.0 + '@smithy/util-defaults-mode-browser': 3.0.25 + '@smithy/util-defaults-mode-node': 3.0.25 + '@smithy/util-endpoints': 2.1.4 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + '@smithy/util-stream': 3.2.1 + '@smithy/util-utf8': 3.0.0 + '@smithy/util-waiter': 3.1.7 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-node': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/middleware-host-header': 3.686.0 + '@aws-sdk/middleware-logger': 3.686.0 + '@aws-sdk/middleware-recursion-detection': 3.686.0 + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/region-config-resolver': 3.686.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@aws-sdk/util-user-agent-browser': 3.686.0 + '@aws-sdk/util-user-agent-node': 3.691.0 + '@smithy/config-resolver': 3.0.10 + '@smithy/core': 2.5.1 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/hash-node': 3.0.8 + '@smithy/invalid-dependency': 3.0.8 + '@smithy/middleware-content-length': 3.0.10 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-retry': 3.0.25 + '@smithy/middleware-serde': 3.0.8 + '@smithy/middleware-stack': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/node-http-handler': 3.2.5 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-base64': 3.0.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-body-length-node': 3.0.0 + '@smithy/util-defaults-mode-browser': 3.0.25 + '@smithy/util-defaults-mode-node': 3.0.25 + '@smithy/util-endpoints': 2.1.4 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.691.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/middleware-host-header': 3.686.0 + '@aws-sdk/middleware-logger': 3.686.0 + '@aws-sdk/middleware-recursion-detection': 3.686.0 + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/region-config-resolver': 3.686.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@aws-sdk/util-user-agent-browser': 3.686.0 + '@aws-sdk/util-user-agent-node': 3.691.0 + '@smithy/config-resolver': 3.0.10 + '@smithy/core': 2.5.1 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/hash-node': 3.0.8 + '@smithy/invalid-dependency': 3.0.8 + '@smithy/middleware-content-length': 3.0.10 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-retry': 3.0.25 + '@smithy/middleware-serde': 3.0.8 + '@smithy/middleware-stack': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/node-http-handler': 3.2.5 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-base64': 3.0.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-body-length-node': 3.0.0 + '@smithy/util-defaults-mode-browser': 3.0.25 + '@smithy/util-defaults-mode-node': 3.0.25 + '@smithy/util-endpoints': 2.1.4 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sts@3.691.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/client-sso-oidc': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-node': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/middleware-host-header': 3.686.0 + '@aws-sdk/middleware-logger': 3.686.0 + '@aws-sdk/middleware-recursion-detection': 3.686.0 + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/region-config-resolver': 3.686.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@aws-sdk/util-user-agent-browser': 3.686.0 + '@aws-sdk/util-user-agent-node': 3.691.0 + '@smithy/config-resolver': 3.0.10 + '@smithy/core': 2.5.1 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/hash-node': 3.0.8 + '@smithy/invalid-dependency': 3.0.8 + '@smithy/middleware-content-length': 3.0.10 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-retry': 3.0.25 + '@smithy/middleware-serde': 3.0.8 + '@smithy/middleware-stack': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/node-http-handler': 3.2.5 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-base64': 3.0.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-body-length-node': 3.0.0 + '@smithy/util-defaults-mode-browser': 3.0.25 + '@smithy/util-defaults-mode-node': 3.0.25 + '@smithy/util-endpoints': 2.1.4 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.691.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/core': 2.5.1 + '@smithy/node-config-provider': 3.1.9 + '@smithy/property-provider': 3.1.8 + '@smithy/protocol-http': 4.1.5 + '@smithy/signature-v4': 4.2.1 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/util-middleware': 3.0.8 + fast-xml-parser: 4.4.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.691.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + optional: true + + '@aws-sdk/credential-provider-env@3.691.0': + dependencies: + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.691.0': + dependencies: + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/node-http-handler': 3.2.5 + '@smithy/property-provider': 3.1.8 + '@smithy/protocol-http': 4.1.5 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/util-stream': 3.2.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0)': + dependencies: + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-env': 3.691.0 + '@aws-sdk/credential-provider-http': 3.691.0 + '@aws-sdk/credential-provider-process': 3.691.0 + '@aws-sdk/credential-provider-sso': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@aws-sdk/credential-provider-web-identity': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/types': 3.686.0 + '@smithy/credential-provider-imds': 3.2.5 + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + + '@aws-sdk/credential-provider-node@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0)': + dependencies: + '@aws-sdk/credential-provider-env': 3.691.0 + '@aws-sdk/credential-provider-http': 3.691.0 + '@aws-sdk/credential-provider-ini': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/credential-provider-process': 3.691.0 + '@aws-sdk/credential-provider-sso': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@aws-sdk/credential-provider-web-identity': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/types': 3.686.0 + '@smithy/credential-provider-imds': 3.2.5 + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - '@aws-sdk/client-sts' + - aws-crt + + '@aws-sdk/credential-provider-process@3.691.0': + dependencies: + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))': + dependencies: + '@aws-sdk/client-sso': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/token-providers': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.691.0(@aws-sdk/client-sts@3.691.0)': + dependencies: + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))': + dependencies: + '@aws-sdk/client-cognito-identity': 3.691.0 + '@aws-sdk/client-sso': 3.691.0 + '@aws-sdk/client-sts': 3.691.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/credential-provider-cognito-identity': 3.691.0 + '@aws-sdk/credential-provider-env': 3.691.0 + '@aws-sdk/credential-provider-http': 3.691.0 + '@aws-sdk/credential-provider-ini': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/credential-provider-node': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/credential-provider-process': 3.691.0 + '@aws-sdk/credential-provider-sso': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@aws-sdk/credential-provider-web-identity': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/types': 3.686.0 + '@smithy/credential-provider-imds': 3.2.5 + '@smithy/property-provider': 3.1.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + optional: true + + '@aws-sdk/lib-storage@3.691.0(@aws-sdk/client-s3@3.691.0)': + dependencies: + '@aws-sdk/client-s3': 3.691.0 + '@smithy/abort-controller': 3.1.6 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/smithy-client': 3.4.2 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-bucket-endpoint@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-arn-parser': 3.679.0 + '@smithy/node-config-provider': 3.1.9 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + '@smithy/util-config-provider': 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.691.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/is-array-buffer': 3.0.0 + '@smithy/node-config-provider': 3.1.9 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-stream': 3.2.1 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.691.0': + dependencies: + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-arn-parser': 3.679.0 + '@smithy/core': 2.5.1 + '@smithy/node-config-provider': 3.1.9 + '@smithy/protocol-http': 4.1.5 + '@smithy/signature-v4': 4.2.1 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/util-config-provider': 3.0.0 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-stream': 3.2.1 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.691.0': + dependencies: + '@aws-sdk/core': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@aws-sdk/util-endpoints': 3.686.0 + '@smithy/core': 2.5.1 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/region-config-resolver@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/node-config-provider': 3.1.9 + '@smithy/types': 3.6.0 + '@smithy/util-config-provider': 3.0.0 + '@smithy/util-middleware': 3.0.8 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.691.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/protocol-http': 4.1.5 + '@smithy/signature-v4': 4.2.1 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))': + dependencies: + '@aws-sdk/client-sso-oidc': 3.691.0(@aws-sdk/client-sts@3.691.0) + '@aws-sdk/types': 3.686.0 + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.686.0': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.679.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/types': 3.6.0 + '@smithy/util-endpoints': 2.1.4 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.679.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.686.0': + dependencies: + '@aws-sdk/types': 3.686.0 + '@smithy/types': 3.6.0 + bowser: 2.11.0 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.691.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.691.0 + '@aws-sdk/types': 3.686.0 + '@smithy/node-config-provider': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.686.0': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.25.9': {} + + '@colors/colors@1.6.0': {} + + '@commitlint/cli@19.5.0(@types/node@20.17.6)(typescript@5.6.3)': + dependencies: + '@commitlint/format': 19.5.0 + '@commitlint/lint': 19.5.0 + '@commitlint/load': 19.5.0(@types/node@20.17.6)(typescript@5.6.3) + '@commitlint/read': 19.5.0 + '@commitlint/types': 19.5.0 + tinyexec: 0.3.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + ajv: 8.17.1 + + '@commitlint/ensure@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.5.0': {} + + '@commitlint/format@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + chalk: 5.3.0 + + '@commitlint/is-ignored@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + semver: 7.6.3 + + '@commitlint/lint@19.5.0': + dependencies: + '@commitlint/is-ignored': 19.5.0 + '@commitlint/parse': 19.5.0 + '@commitlint/rules': 19.5.0 + '@commitlint/types': 19.5.0 + + '@commitlint/load@19.5.0(@types/node@20.17.6)(typescript@5.6.3)': + dependencies: + '@commitlint/config-validator': 19.5.0 + '@commitlint/execute-rule': 19.5.0 + '@commitlint/resolve-extends': 19.5.0 + '@commitlint/types': 19.5.0 + chalk: 5.3.0 + cosmiconfig: 9.0.0(typescript@5.6.3) + cosmiconfig-typescript-loader: 5.1.0(@types/node@20.17.6)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@19.5.0': {} + + '@commitlint/parse@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.5.0': + dependencies: + '@commitlint/top-level': 19.5.0 + '@commitlint/types': 19.5.0 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 0.3.1 + + '@commitlint/resolve-extends@19.5.0': + dependencies: + '@commitlint/config-validator': 19.5.0 + '@commitlint/types': 19.5.0 + global-directory: 4.0.1 + import-meta-resolve: 4.1.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@19.5.0': + dependencies: + '@commitlint/ensure': 19.5.0 + '@commitlint/message': 19.5.0 + '@commitlint/to-lines': 19.5.0 + '@commitlint/types': 19.5.0 + + '@commitlint/to-lines@19.5.0': {} + + '@commitlint/top-level@19.5.0': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.5.0': + dependencies: + '@types/conventional-commits-parser': 5.0.0 + chalk: 5.3.0 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@dabh/diagnostics@2.0.3': + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + + '@esbuild/aix-ppc64@0.23.1': + optional: true + + '@esbuild/android-arm64@0.23.1': + optional: true + + '@esbuild/android-arm@0.23.1': + optional: true + + '@esbuild/android-x64@0.23.1': + optional: true + + '@esbuild/darwin-arm64@0.23.1': + optional: true + + '@esbuild/darwin-x64@0.23.1': + optional: true + + '@esbuild/freebsd-arm64@0.23.1': + optional: true + + '@esbuild/freebsd-x64@0.23.1': + optional: true + + '@esbuild/linux-arm64@0.23.1': + optional: true + + '@esbuild/linux-arm@0.23.1': + optional: true + + '@esbuild/linux-ia32@0.23.1': + optional: true + + '@esbuild/linux-loong64@0.23.1': + optional: true + + '@esbuild/linux-mips64el@0.23.1': + optional: true + + '@esbuild/linux-ppc64@0.23.1': + optional: true + + '@esbuild/linux-riscv64@0.23.1': + optional: true + + '@esbuild/linux-s390x@0.23.1': + optional: true + + '@esbuild/linux-x64@0.23.1': + optional: true + + '@esbuild/netbsd-x64@0.23.1': + optional: true + + '@esbuild/openbsd-arm64@0.23.1': + optional: true + + '@esbuild/openbsd-x64@0.23.1': + optional: true + + '@esbuild/sunos-x64@0.23.1': + optional: true + + '@esbuild/win32-arm64@0.23.1': + optional: true + + '@esbuild/win32-ia32@0.23.1': + optional: true + + '@esbuild/win32-x64@0.23.1': + optional: true + + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.3.7(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@faker-js/faker@7.6.0': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.3.7(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@inversifyjs/common@1.3.3': {} + + '@inversifyjs/core@1.3.4(reflect-metadata@0.2.2)': + dependencies: + '@inversifyjs/common': 1.3.3 + '@inversifyjs/reflect-metadata-utils': 0.2.3(reflect-metadata@0.2.2) + transitivePeerDependencies: + - reflect-metadata + + '@inversifyjs/reflect-metadata-utils@0.2.3(reflect-metadata@0.2.2)': + dependencies: + reflect-metadata: 0.2.2 + + '@ioredis/commands@1.2.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@lukeed/csprng@1.1.0': {} + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.3 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.6.3 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@mongodb-js/saslprep@1.1.9': + dependencies: + sparse-bitfield: 3.0.3 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@nestjs/common@10.4.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + tslib: 2.7.0 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/mapped-types@2.0.6(@nestjs/common@10.4.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.8(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@pkgr/core@0.1.1': {} + + '@rtsao/scc@1.1.0': {} + + '@scarf/scarf@1.4.0': {} + + '@smithy/abort-controller@3.1.6': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@3.0.1': + dependencies: + '@smithy/util-base64': 3.0.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@4.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@3.0.10': + dependencies: + '@smithy/node-config-provider': 3.1.9 + '@smithy/types': 3.6.0 + '@smithy/util-config-provider': 3.0.0 + '@smithy/util-middleware': 3.0.8 + tslib: 2.8.1 + + '@smithy/core@2.5.1': + dependencies: + '@smithy/middleware-serde': 3.0.8 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + '@smithy/util-body-length-browser': 3.0.0 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-stream': 3.2.1 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@3.2.5': + dependencies: + '@smithy/node-config-provider': 3.1.9 + '@smithy/property-provider': 3.1.8 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@3.1.7': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 3.6.0 + '@smithy/util-hex-encoding': 3.0.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@3.0.11': + dependencies: + '@smithy/eventstream-serde-universal': 3.0.10 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@3.0.10': + dependencies: + '@smithy/eventstream-serde-universal': 3.0.10 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@3.0.10': + dependencies: + '@smithy/eventstream-codec': 3.1.7 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@4.0.0': + dependencies: + '@smithy/protocol-http': 4.1.5 + '@smithy/querystring-builder': 3.0.8 + '@smithy/types': 3.6.0 + '@smithy/util-base64': 3.0.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@3.1.7': + dependencies: + '@smithy/chunked-blob-reader': 4.0.0 + '@smithy/chunked-blob-reader-native': 3.0.1 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/hash-node@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + '@smithy/util-buffer-from': 3.0.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@3.1.7': + dependencies: + '@smithy/types': 3.6.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@3.0.10': + dependencies: + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@3.2.1': + dependencies: + '@smithy/core': 2.5.1 + '@smithy/middleware-serde': 3.0.8 + '@smithy/node-config-provider': 3.1.9 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + '@smithy/url-parser': 3.0.8 + '@smithy/util-middleware': 3.0.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@3.0.25': + dependencies: + '@smithy/node-config-provider': 3.1.9 + '@smithy/protocol-http': 4.1.5 + '@smithy/service-error-classification': 3.0.8 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-retry': 3.0.8 + tslib: 2.8.1 + uuid: 9.0.1 + + '@smithy/middleware-serde@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@3.1.9': + dependencies: + '@smithy/property-provider': 3.1.8 + '@smithy/shared-ini-file-loader': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@3.2.5': + dependencies: + '@smithy/abort-controller': 3.1.6 + '@smithy/protocol-http': 4.1.5 + '@smithy/querystring-builder': 3.0.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/property-provider@3.1.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/protocol-http@4.1.5': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + '@smithy/util-uri-escape': 3.0.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + + '@smithy/shared-ini-file-loader@3.1.9': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/signature-v4@4.2.1': + dependencies: + '@smithy/is-array-buffer': 3.0.0 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + '@smithy/util-hex-encoding': 3.0.0 + '@smithy/util-middleware': 3.0.8 + '@smithy/util-uri-escape': 3.0.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/smithy-client@3.4.2': + dependencies: + '@smithy/core': 2.5.1 + '@smithy/middleware-endpoint': 3.2.1 + '@smithy/middleware-stack': 3.0.8 + '@smithy/protocol-http': 4.1.5 + '@smithy/types': 3.6.0 + '@smithy/util-stream': 3.2.1 + tslib: 2.8.1 + + '@smithy/types@3.6.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@3.0.8': + dependencies: + '@smithy/querystring-parser': 3.0.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/util-base64@3.0.0': + dependencies: + '@smithy/util-buffer-from': 3.0.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@3.0.0': + dependencies: + '@smithy/is-array-buffer': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@3.0.25': + dependencies: + '@smithy/property-provider': 3.1.8 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + bowser: 2.11.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@3.0.25': + dependencies: + '@smithy/config-resolver': 3.0.10 + '@smithy/credential-provider-imds': 3.2.5 + '@smithy/node-config-provider': 3.1.9 + '@smithy/property-provider': 3.1.8 + '@smithy/smithy-client': 3.4.2 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@2.1.4': + dependencies: + '@smithy/node-config-provider': 3.1.9 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@3.0.8': + dependencies: + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/util-retry@3.0.8': + dependencies: + '@smithy/service-error-classification': 3.0.8 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@smithy/util-stream@3.2.1': + dependencies: + '@smithy/fetch-http-handler': 4.0.0 + '@smithy/node-http-handler': 3.2.5 + '@smithy/types': 3.6.0 + '@smithy/util-base64': 3.0.0 + '@smithy/util-buffer-from': 3.0.0 + '@smithy/util-hex-encoding': 3.0.0 + '@smithy/util-utf8': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@3.0.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@3.0.0': + dependencies: + '@smithy/util-buffer-from': 3.0.0 + tslib: 2.8.1 + + '@smithy/util-waiter@3.1.7': + dependencies: + '@smithy/abort-controller': 3.1.6 + '@smithy/types': 3.6.0 + tslib: 2.8.1 + + '@socket.io/component-emitter@3.1.2': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.17.6 + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.17.6 + + '@types/compression@1.7.5': + dependencies: + '@types/express': 4.17.21 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.17.6 + + '@types/conventional-commits-parser@5.0.0': + dependencies: + '@types/node': 20.17.6 + + '@types/cookie@0.4.1': {} + + '@types/cors@2.8.17': + dependencies: + '@types/node': 20.17.6 + + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 20.17.6 + '@types/qs': 6.9.17 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.9.17 + '@types/serve-static': 1.15.7 + + '@types/http-errors@2.0.4': {} + + '@types/json5@0.0.29': {} + + '@types/jsonwebtoken@9.0.7': + dependencies: + '@types/node': 20.17.6 + + '@types/mime@1.3.5': {} + + '@types/mongoose-sequence@3.0.11(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0))': + dependencies: + '@types/node': 20.17.6 + mongoose: 6.13.3(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + - supports-color + + '@types/morgan@1.9.9': + dependencies: + '@types/node': 20.17.6 + + '@types/multer-s3@3.0.3': + dependencies: + '@aws-sdk/client-s3': 3.691.0 + '@types/multer': 1.4.12 + '@types/node': 20.17.6 + transitivePeerDependencies: + - aws-crt + + '@types/multer@1.4.12': + dependencies: + '@types/express': 4.17.21 + + '@types/node@20.17.6': + dependencies: + undici-types: 6.19.8 + + '@types/nodemailer@6.4.16': + dependencies: + '@types/node': 20.17.6 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.7 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 4.17.21 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.21 + + '@types/qs@6.9.17': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.17.6 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.17.6 + '@types/send': 0.17.4 + + '@types/swagger-ui-express@4.1.7': + dependencies: + '@types/express': 4.17.21 + '@types/serve-static': 1.15.7 + + '@types/triple-beam@1.3.5': {} + + '@types/validator@13.12.2': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@11.0.5': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@types/whatwg-url@8.2.2': + dependencies: + '@types/node': 20.17.6 + '@types/webidl-conversions': 7.0.3 + + '@typescript-eslint/eslint-plugin@8.14.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.14.0 + '@typescript-eslint/type-utils': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/utils': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.14.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.14.0 + '@typescript-eslint/types': 8.14.0 + '@typescript-eslint/typescript-estree': 8.14.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.14.0 + debug: 4.3.7(supports-color@5.5.0) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.14.0': + dependencies: + '@typescript-eslint/types': 8.14.0 + '@typescript-eslint/visitor-keys': 8.14.0 + + '@typescript-eslint/type-utils@8.14.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.14.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + debug: 4.3.7(supports-color@5.5.0) + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - eslint + - supports-color + + '@typescript-eslint/types@8.14.0': {} + + '@typescript-eslint/typescript-estree@8.14.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.14.0 + '@typescript-eslint/visitor-keys': 8.14.0 + debug: 4.3.7(supports-color@5.5.0) + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.4.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.14.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.14.0 + '@typescript-eslint/types': 8.14.0 + '@typescript-eslint/typescript-estree': 8.14.0(typescript@5.6.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@8.14.0': + dependencies: + '@typescript-eslint/types': 8.14.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + abbrev@1.1.1: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + + acorn@8.14.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.3.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + append-field@1.0.0: {} + + aproba@2.0.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@4.1.3: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + + array-flatten@1.1.1: {} + + array-ify@1.0.0: {} + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.flat@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-shim-unscopables: 1.0.2 + + array.prototype.flatmap@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-shim-unscopables: 1.0.2 + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + base64id@2.0.0: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bowser@2.11.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bson@4.7.2: + dependencies: + buffer: 5.7.1 + + bson@6.9.0: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bullmq@5.25.6: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.4.1 + msgpackr: 1.11.2 + node-abort-controller: 3.1.1 + semver: 7.6.3 + tslib: 2.8.1 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + chardet@0.7.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@2.0.0: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.1: + dependencies: + '@types/validator': 13.12.2 + libphonenumber-js: 1.11.14 + validator: 13.12.0 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cli-width@3.0.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clone@2.1.2: {} + + cluster-key-slot@1.1.2: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color-support@1.1.3: {} + + color@3.2.1: + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + + colorette@2.0.20: {} + + colorspace@1.1.4: + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@12.1.0: {} + + commander@7.2.0: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + compressible@2.0.18: + dependencies: + mime-db: 1.53.0 + + compression@1.7.5: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.0.2 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + console-control-strings@1.1.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig-typescript-loader@5.1.0(@types/node@20.17.6)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3): + dependencies: + '@types/node': 20.17.6 + cosmiconfig: 9.0.0(typescript@5.6.3) + jiti: 1.21.6 + typescript: 5.6.3 + + cosmiconfig@9.0.0(typescript@5.6.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.6.3 + + create-require@1.1.1: {} + + cron-parser@4.9.0: + dependencies: + luxon: 3.5.0 + + cross-spawn@7.0.5: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csv-generate@4.4.2: {} + + csv-parse@5.6.0: {} + + csv-stringify@6.5.2: {} + + csv@6.3.11: + dependencies: + csv-generate: 4.4.2 + csv-parse: 5.6.0 + csv-stringify: 6.5.2 + stream-transform: 3.3.3 + + dargs@8.1.0: {} + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + dayjs@1.11.13: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.3.7(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + deep-is@0.1.4: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.0.3: {} + + diff@4.0.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv@16.4.5: {} + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enabled@2.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + engine.io-parser@5.2.3: {} + + engine.io@6.6.2: + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 20.17.6 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7(supports-color@5.5.0) + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.23.4: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.3 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.3 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + esbuild@0.23.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.14.0(eslint@8.57.1)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + string.prototype.trimend: 1.0.8 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.14.0(eslint@8.57.1)(typescript@5.6.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): + dependencies: + eslint: 8.57.1 + prettier: 3.3.3 + prettier-linter-helpers: 1.0.0 + synckit: 0.9.2 + optionalDependencies: + eslint-config-prettier: 9.1.0(eslint@8.57.1) + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.5 + debug: 4.3.7(supports-color@5.5.0) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.1: {} + + events@3.3.0: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.5 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + express-basic-auth@1.2.1: + dependencies: + basic-auth: 2.0.1 + + express-rate-limit@7.4.1(express@4.21.1): + dependencies: + express: 4.21.1 + + express@4.21.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.10 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.0.3: {} + + fast-xml-parser@4.4.1: + dependencies: + strnum: 1.0.5 + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fecha@4.2.3: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-type@3.9.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.1: {} + + fn.name@1.1.0: {} + + follow-redirects@1.15.9: {} + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.5 + signal-exit: 4.1.0 + + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + functions-have-names: 1.2.3 + + functions-have-names@1.2.3: {} + + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-stream@8.0.1: {} + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + + get-tsconfig@4.8.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@11.0.0: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-bigints@1.0.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.0.3 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-comment-regex@1.1.2: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-status-codes@2.3.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.3.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + human-signals@5.0.0: {} + + husky@9.1.6: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.1.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@4.1.1: {} + + inquirer@8.2.6: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + + inversify-express-utils@6.4.6(reflect-metadata@0.2.2): + dependencies: + express: 4.21.1 + http-status-codes: 2.3.0 + inversify: 6.1.4(reflect-metadata@0.2.2) + transitivePeerDependencies: + - reflect-metadata + - supports-color + + inversify@6.1.4(reflect-metadata@0.2.2): + dependencies: + '@inversifyjs/common': 1.3.3 + '@inversifyjs/core': 1.3.4(reflect-metadata@0.2.2) + transitivePeerDependencies: + - reflect-metadata + + ioredis@5.4.1: + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.7(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ip-address@9.0.5: + dependencies: + jsbn: 1.1.0 + sprintf-js: 1.1.3 + + ipaddr.js@1.9.1: {} + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.2: {} + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + + is-unicode-supported@0.1.0: {} + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterare@1.2.1: {} + + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + + jalali-moment@3.3.11: + dependencies: + commander: 7.2.0 + inquirer: 8.2.6 + moment: 2.30.1 + + jiti@1.21.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@1.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsonparse@1.3.1: {} + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + kareem@2.5.1: {} + + kareem@2.6.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kuler@2.0.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.11.14: {} + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + lint-staged@15.2.10: + dependencies: + chalk: 5.3.0 + commander: 12.1.0 + debug: 4.3.7(supports-color@5.5.0) + execa: 8.0.1 + lilconfig: 3.1.2 + listr2: 8.2.5 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.5.1 + transitivePeerDependencies: + - supports-color + + listr2@8.2.5: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.defaults@4.2.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.kebabcase@4.1.1: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + lru-cache@11.0.2: {} + + luxon@3.5.0: {} + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-error@1.3.6: {} + + media-typer@0.3.0: {} + + memory-pager@1.5.0: {} + + meow@12.1.1: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.53.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@1.0.4: {} + + moment@2.30.1: {} + + mongodb-connection-string-url@2.6.0: + dependencies: + '@types/whatwg-url': 8.2.2 + whatwg-url: 11.0.0 + + mongodb-connection-string-url@3.0.1: + dependencies: + '@types/whatwg-url': 11.0.5 + whatwg-url: 13.0.0 + + mongodb@4.17.2(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)): + dependencies: + bson: 4.7.2 + mongodb-connection-string-url: 2.6.0 + socks: 2.8.3 + optionalDependencies: + '@aws-sdk/credential-providers': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + '@mongodb-js/saslprep': 1.1.9 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + + mongodb@6.10.0(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3): + dependencies: + '@mongodb-js/saslprep': 1.1.9 + bson: 6.9.0 + mongodb-connection-string-url: 3.0.1 + optionalDependencies: + '@aws-sdk/credential-providers': 3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + socks: 2.8.3 + + mongoose-sequence@6.0.1(mongoose@8.8.1(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3)): + dependencies: + async: 3.2.6 + lodash: 4.17.21 + mongoose: 8.8.1(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3) + + mongoose@6.13.3(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)): + dependencies: + bson: 4.7.2 + kareem: 2.5.1 + mongodb: 4.17.2(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)) + mpath: 0.9.0 + mquery: 4.0.3 + ms: 2.1.3 + sift: 16.0.1 + transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' + - aws-crt + - supports-color + + mongoose@8.8.1(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3): + dependencies: + bson: 6.9.0 + kareem: 2.6.3 + mongodb: 6.10.0(@aws-sdk/credential-providers@3.691.0(@aws-sdk/client-sso-oidc@3.691.0(@aws-sdk/client-sts@3.691.0)))(socks@2.8.3) + mpath: 0.9.0 + mquery: 5.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + - supports-color + + morgan@1.10.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.0.2 + transitivePeerDependencies: + - supports-color + + mpath@0.9.0: {} + + mquery@4.0.3: + dependencies: + debug: 4.3.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + mquery@5.0.0: + dependencies: + debug: 4.3.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.2: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multer-s3@3.0.1(@aws-sdk/client-s3@3.691.0): + dependencies: + '@aws-sdk/client-s3': 3.691.0 + '@aws-sdk/lib-storage': 3.691.0(@aws-sdk/client-s3@3.691.0) + file-type: 3.9.0 + html-comment-regex: 1.1.2 + run-parallel: 1.2.0 + + multer@1.4.5-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mute-stream@0.0.8: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + + node-addon-api@5.1.0: {} + + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + + nodemailer@6.9.16: {} + + nodemon@3.1.7: + dependencies: + chokidar: 3.6.0 + debug: 4.3.7(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.6.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.3: {} + + object-keys@1.1.1: {} + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-object-atoms: 1.0.0 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-tmpdir@1.0.2: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.1.1 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.2 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.2 + minipass: 7.1.2 + + path-to-regexp@0.1.10: {} + + pause@0.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pidtree@0.6.0: {} + + possible-typed-array-names@1.0.0: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.3.3: {} + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pstree.remy@1.1.8: {} + + punycode@2.3.1: {} + + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + reflect-metadata@0.2.2: {} + + regexp.prototype.flags@1.5.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rimraf@6.0.1: + dependencies: + glob: 11.0.0 + package-json-from-dist: 1.0.1 + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.3 + + sift@16.0.1: {} + + sift@17.1.3: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.6.3 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + slugify@1.6.6: {} + + smart-buffer@4.2.0: {} + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7(supports-color@5.5.0) + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7(supports-color@5.5.0) + engine.io: 6.6.2 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socks@2.8.3: + dependencies: + ip-address: 9.0.5 + smart-buffer: 4.2.0 + + source-map@0.6.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + split2@4.2.0: {} + + sprintf-js@1.1.3: {} + + stack-trace@0.0.10: {} + + standard-as-callback@2.1.0: {} + + statuses@2.0.1: {} + + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-transform@3.3.3: {} + + streamsearch@1.1.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.4 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-json-comments@3.1.1: {} + + strnum@1.0.5: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + swagger-ui-dist@5.18.2: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@4.21.1): + dependencies: + express: 4.21.1 + swagger-ui-dist: 5.18.2 + + synckit@0.9.2: + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.8.1 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + text-extensions@2.4.0: {} + + text-hex@1.0.0: {} + + text-table@0.2.0: {} + + through@2.3.8: {} + + tinyexec@0.3.1: {} + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + tr46@0.0.3: {} + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + tr46@4.1.1: + dependencies: + punycode: 2.3.1 + + triple-beam@1.4.1: {} + + ts-api-utils@1.4.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.6 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.6.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.7.0: {} + + tslib@2.8.1: {} + + tsx@4.19.2: + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.8.1 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + + typedarray@0.0.6: {} + + typescript@5.6.3: {} + + uglify-js@3.19.3: + optional: true + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + undefsafe@2.0.5: {} + + undici-types@6.19.8: {} + + unicorn-magic@0.1.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@9.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + validator@13.12.0: {} + + vary@1.1.2: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + whatwg-url@13.0.0: + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.17.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.17.1: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yaml@2.5.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.1.1: {} diff --git a/src/IOC/ioc.config.ts b/src/IOC/ioc.config.ts new file mode 100644 index 0000000..956d5a7 --- /dev/null +++ b/src/IOC/ioc.config.ts @@ -0,0 +1,286 @@ +import { AsyncContainerModule } from "inversify"; + +import { IOCTYPES } from "./ioc.types"; +import { AboutUsRepo, CreateAboutUsRepo } from "../modules/about-us/aboutUs.repository"; +import { AboutUsService } from "../modules/about-us/aboutUs.service"; +import { + AddressRepo, + CityRepo, + ProvinceRepo, + createAddressRepo, + createCityRepo, + createProvinceRepo, +} from "../modules/address/address.repository"; +import { AddressService } from "../modules/address/address.service"; +import { AdminService } from "../modules/admin/admin.service"; +import { AdminRepository, createAdminRepository } from "../modules/admin/repository/admin"; +import { ContractRepository, createContractRepository } from "../modules/admin/repository/contract"; +import { BannerRepository, SliderRepository, createBannerRepo, createSliderRepo } from "../modules/admin/repository/media"; +import { PermissionRepository, createPermissionRepo } from "../modules/admin/repository/permission"; +import { RoleRepository, createRoleRepo } from "../modules/admin/repository/role"; +import { AuthService } from "../modules/auth/auth.service"; +import { BlogService } from "../modules/blog/blog.service"; +import { BlogRepository, createBlogRepo } from "../modules/blog/repository/blog"; +import { BlogCategoryRepo, createBlogCategoryRepo } from "../modules/blog/repository/blogCategory"; +import { BrandRepository, createBrandRepository } from "../modules/brand/brand.repository"; +import { BrandService } from "../modules/brand/brand.service"; +import { + CancelOrderItemRepo, + CancelOrderRepo, + CancelReasonRepo, + createCancelOrderItemRepo, + createCancelOrderRepo, + createCancelReasonRepo, +} from "../modules/cancel/cancel.repository"; +import { CancelService } from "../modules/cancel/cancel.service"; +import { CartRepository, CartShipItemRepo, createCartRepo, createCartShipItemRepo } from "../modules/cart/cart.repository"; +import { CartService } from "../modules/cart/cart.service"; +import { + AttributeValueRepo, + CategoryAttributeRepo, + CategoryRepository, + ColorRepository, + MeterageRepository, + SizeRepository, + createAttributeValueRepo, + createCategoryAttributeRepo, + createCategoryRepo, + createColorRepo, + createMeterageRepo, + createSizeRepo, +} from "../modules/category/category.repository"; +import { CategoryService } from "../modules/category/category.service"; +import { ChatGateway } from "../modules/chat/chat.gateway"; +import { ChatService } from "../modules/chat/chat.service"; +import { ChatRepo, createChatRepo } from "../modules/chat/repository/chat.repository"; +import { ChatMessageRepo, createChatMessageRepo } from "../modules/chat/repository/message.repository"; +import { WsAuthService } from "../modules/chat/wsAuth.service"; +import { ContactUsRepo, CreateContactUsRepo } from "../modules/contact-us/contactUs.repository"; +import { ContactUsService } from "../modules/contact-us/contactUs.service"; +import { CouponRepo, CouponUsageRepo, createCouponRepo, createCouponUsageRepo } from "../modules/Coupon/coupon.repository"; +import { CouponService } from "../modules/Coupon/coupon.service"; +import { FaqRepo, createFaqRepo } from "../modules/faq/faq.repository"; +import { FaqService } from "../modules/faq/faq.service"; +import { FineService } from "../modules/fine/fine.service"; +import { FineRepo, createFineRepo } from "../modules/fine/repository/fine.repository"; +import { FineRuleRepo, createFineRuleRepo } from "../modules/fine/repository/fineRule.repository"; +import { AsanPardakhtGateway } from "../modules/IPG/gateways/asanpardakht"; +import { ZarinPalGateway } from "../modules/IPG/gateways/zarinpal"; +import { PaymentGateway } from "../modules/IPG/PaymentGateway"; +import { JobService } from "../modules/job/job.service"; +import { JobRepo, createJobRepo } from "../modules/job/repository/job.repo"; +import { ResumeRepo, createResumeRepo } from "../modules/job/repository/resume.repo"; +import { LandingService } from "../modules/landing/landing.service"; +import { LearningService } from "../modules/learning/learning.service"; +import { CreateLearningRepo, LearningRepo } from "../modules/learning/repository/learning"; +import { CreateLearningCategoryRepo, LearningCategoryRepo } from "../modules/learning/repository/learningCategory"; +import { CreateLearningProgressRepo, LearningProgressRepo } from "../modules/learning/repository/learningProgress"; +import { MediaService } from "../modules/media/media.service"; +import { CreateNewsletterRepo, NewsletterRepo } from "../modules/newsletter/newsletter.repository"; +import { NewsletterService } from "../modules/newsletter/newsletter.service"; +import { NotificationRepo, createNotificationRepo } from "../modules/notification/notification.repository"; +import { NotificationService } from "../modules/notification/notification.service"; +import { OrderItemRepo, OrderRepository, createOrderItemRepo, createOrderRepo } from "../modules/order/order.repository"; +import { OrderService } from "../modules/order/order.service"; +import { + CartPaymentRepository, + PRPaymentRepository, + PaymentMethodRepo, + createCartPaymentRepo, + createPRPaymentRepo, + createPaymentMethodRepo, +} from "../modules/payment/payment.repository"; +import { PaymentService } from "../modules/payment/providers/payment.service"; +import { PRPaymentService } from "../modules/payment/providers/PR-payment.service"; +import { PricingRepository, createPricingRepo } from "../modules/pricing/pricing.repository"; +import { PricingService } from "../modules/pricing/pricing.service"; +import { ProductRequestService } from "../modules/product/providers/product-request.service"; +import { ProductService } from "../modules/product/providers/product.service"; +import { CommentRepository, createCommentRepo } from "../modules/product/Repository/comment"; +import { IncredibleOffersRepo, createIncredibleOffersRepo } from "../modules/product/Repository/incredibleOffers"; +import { PopularProductRepo, createPopularProductRepo } from "../modules/product/Repository/popularProduct"; +import { PriceHistoryRepo, createPriceHistoryRepo } from "../modules/product/Repository/pricehistory"; +import { ProductRepository, createProductRepo } from "../modules/product/Repository/product"; +import { ProductAdRepo, createProductAdRepo } from "../modules/product/Repository/productAd"; +import { ProductObserveRepo, createProductObserveRepo } from "../modules/product/Repository/productObserve"; +import { ProductReportRepo, createProductReportRepo } from "../modules/product/Repository/productReport"; +import { ProductRequestRepo, createProductRequestRepo } from "../modules/product/Repository/productRequest"; +import { ProductVariantRepository, createProductVariantRepo } from "../modules/product/Repository/productVarinat"; +import { QuestionRepository, createQuestionRepo } from "../modules/product/Repository/question"; +import { ReportQuestionRepo, createReportQuestionRepo } from "../modules/product/Repository/reportQuestion"; +import { + ReturnOrderItemRepo, + ReturnOrderRepo, + ReturnReasonRepo, + createReturnOrderItemRepo, + createReturnOrderRepo, + createReturnReasonRepo, +} from "../modules/return/return.repository"; +import { ReturnService } from "../modules/return/return.service"; +import { DocumentTypeRepo, createDocumentTypeRepo } from "../modules/seller/repository/documentType.repository"; +import { LegalSellerRepo, createLegalSellerRepo } from "../modules/seller/repository/legalSeller.repository"; +import { RealSellerRepo, createRealSellerRepo } from "../modules/seller/repository/realSeller.repository"; +import { SellerContractRepo, createSellerContractRepo } from "../modules/seller/repository/sellerContract.repository"; +import { SellerDocumentRepo, createSellerDocumentRepo } from "../modules/seller/repository/sellerDocument.repository"; +import { SellerStatusRepo, createSellerStatusRepo } from "../modules/seller/repository/sellerStatus.repository"; +import { WholesaleRequestRepo, createWholesaleRequestRepo } from "../modules/seller/repository/wholesaleRequest.repositroy"; +import { SellerRepository, createSellerRepository } from "../modules/seller/seller.repository"; +import { SellerService } from "../modules/seller/seller.service"; +import { ShipmentRepository, createShipmentRepository } from "../modules/shipment/shipment.repository"; +import { ShipmentService } from "../modules/shipment/shipment.service"; +import { ShopRepo, createShopRepo } from "../modules/shop/shop.repository"; +import { ShopService } from "../modules/shop/shop.service"; +import { CreateSiteSettingRepo, SiteSettingRepo } from "../modules/site-setting/siteSetting.repository"; +import { SiteSettingService } from "../modules/site-setting/siteSetting.service"; +import { TicketRepository, createTicketRepo } from "../modules/ticket/repository/ticket"; +import { TicketCategoryRepository, createTicketCategoryRepo } from "../modules/ticket/repository/ticketCategory"; +import { TicketMessageRepository, createTicketMessageRepo } from "../modules/ticket/repository/ticketMessage"; +import { TicketService } from "../modules/ticket/ticket.service"; +import { TokenRepository, createTokenRepository } from "../modules/token/token.repository"; +import { TokenService } from "../modules/token/token.service"; +import { UserRepository, WishlistRepo, createUserRepository, createWishlistRepo } from "../modules/user/user.repository"; +import { UserService } from "../modules/user/user.service"; +import { TransactionRepo, createTransactionRepo } from "../modules/wallet/repository/transaction.repo"; +import { WalletRepo, createWalletRepo } from "../modules/wallet/repository/wallet.repo"; +import { WithdrawalRepo, createWithdrawalRepo } from "../modules/wallet/repository/withdrawal.repo"; +import { WalletService } from "../modules/wallet/wallet.service"; +import { WarrantyRepository, createWarrantyRepository } from "../modules/warranty/warranty.repository"; +import { WarrantyService } from "../modules/warranty/warranty.service"; +import { CacheService } from "../utils/cache.service"; +import { PassportAuth } from "../utils/passport.service"; +import { RedisService } from "../utils/redis.service"; + +const containerModules = new AsyncContainerModule(async (bind) => { + bind(IOCTYPES.PassportAuth).to(PassportAuth).inSingletonScope(); + // bind(IOCTYPES.PassportAuth).to(PassportAuth).inSingletonScope(); + bind(IOCTYPES.PaymentGateway).to(PaymentGateway).inSingletonScope(); + bind(IOCTYPES.ZarinPalGateway).to(ZarinPalGateway).inSingletonScope(); + bind(IOCTYPES.AsanPardakhtGateway).to(AsanPardakhtGateway).inSingletonScope(); + // #region services + bind(IOCTYPES.CategoryService).to(CategoryService).inSingletonScope(); + bind(IOCTYPES.AuthService).to(AuthService).inSingletonScope(); + bind(IOCTYPES.ChatGateway).to(ChatGateway).inSingletonScope(); + bind(IOCTYPES.ChatService).to(ChatService).inSingletonScope(); + bind(IOCTYPES.WsAuthService).to(WsAuthService).inSingletonScope(); + bind(IOCTYPES.TokenService).to(TokenService).inSingletonScope(); + bind(IOCTYPES.SellerService).to(SellerService).inSingletonScope(); + bind(IOCTYPES.UserService).to(UserService).inSingletonScope(); + bind(IOCTYPES.AdminService).to(AdminService).inSingletonScope(); + bind(IOCTYPES.ProductService).to(ProductService).inSingletonScope(); + bind(IOCTYPES.ProductRequestService).to(ProductRequestService).inSingletonScope(); + bind(IOCTYPES.BrandService).to(BrandService).inSingletonScope(); + bind(IOCTYPES.WarrantyService).to(WarrantyService).inSingletonScope(); + bind(IOCTYPES.ShipmentService).to(ShipmentService).inSingletonScope(); + bind(IOCTYPES.CartService).to(CartService).inSingletonScope(); + bind(IOCTYPES.PaymentService).to(PaymentService).inSingletonScope(); + bind(IOCTYPES.PRPaymentService).to(PRPaymentService).inSingletonScope(); + bind(IOCTYPES.OrderService).to(OrderService).inSingletonScope(); + bind(IOCTYPES.CouponService).to(CouponService).inSingletonScope(); + bind(IOCTYPES.LandingService).to(LandingService).inSingletonScope(); + bind(IOCTYPES.BlogService).to(BlogService).inSingletonScope(); + bind(IOCTYPES.TicketService).to(TicketService).inSingletonScope(); + bind(IOCTYPES.WalletService).to(WalletService).inSingletonScope(); + bind(IOCTYPES.JobService).to(JobService).inSingletonScope(); + bind(IOCTYPES.FaqService).to(FaqService).inSingletonScope(); + bind(IOCTYPES.SiteSettingService).to(SiteSettingService).inSingletonScope(); + bind(IOCTYPES.NotificationService).to(NotificationService).inSingletonScope(); + bind(IOCTYPES.ReturnService).to(ReturnService).inSingletonScope(); + bind(IOCTYPES.CancelService).to(CancelService).inSingletonScope(); + bind(IOCTYPES.FineService).to(FineService).inSingletonScope(); + bind(IOCTYPES.AddressService).to(AddressService).inSingletonScope(); + bind(IOCTYPES.ShopService).to(ShopService).inSingletonScope(); + bind(IOCTYPES.MediaService).to(MediaService).inSingletonScope(); + bind(IOCTYPES.ContactUsService).to(ContactUsService).inSingletonScope(); + bind(IOCTYPES.AboutUsService).to(AboutUsService).inSingletonScope(); + bind(IOCTYPES.NewsletterService).to(NewsletterService).inSingletonScope(); + bind(IOCTYPES.PricingService).to(PricingService).inSingletonScope(); + bind(IOCTYPES.LearningService).to(LearningService).inSingletonScope(); + bind(IOCTYPES.RedisService).to(RedisService).inSingletonScope(); + bind(IOCTYPES.CacheService).to(CacheService).inSingletonScope(); + // #endregion + // #region repository + bind(IOCTYPES.CategoryRepository).toDynamicValue(createCategoryRepo).inSingletonScope(); + bind(IOCTYPES.CategoryAttributeRepository).toDynamicValue(createCategoryAttributeRepo).inSingletonScope(); + bind(IOCTYPES.AttributeValueRepository).toDynamicValue(createAttributeValueRepo).inSingletonScope(); + bind(IOCTYPES.TokenRepository).toDynamicValue(createTokenRepository).inSingletonScope(); + bind(IOCTYPES.UserRepository).toDynamicValue(createUserRepository).inSingletonScope(); + bind(IOCTYPES.AdminRepository).toDynamicValue(createAdminRepository).inSingletonScope(); + bind(IOCTYPES.SellerRepository).toDynamicValue(createSellerRepository).inSingletonScope(); + bind(IOCTYPES.LegalSellerRepo).toDynamicValue(createLegalSellerRepo).inSingletonScope(); + bind(IOCTYPES.RealSellerRepo).toDynamicValue(createRealSellerRepo).inSingletonScope(); + bind(IOCTYPES.SellerDocumentRepo).toDynamicValue(createSellerDocumentRepo).inSingletonScope(); + bind(IOCTYPES.SellerContractRepo).toDynamicValue(createSellerContractRepo).inSingletonScope(); + bind(IOCTYPES.SellerStatusRepo).toDynamicValue(createSellerStatusRepo).inSingletonScope(); + bind(IOCTYPES.DocumentTypeRepo).toDynamicValue(createDocumentTypeRepo).inSingletonScope(); + bind(IOCTYPES.BrandRepository).toDynamicValue(createBrandRepository).inSingletonScope(); + bind(IOCTYPES.WarrantyRepository).toDynamicValue(createWarrantyRepository).inSingletonScope(); + bind(IOCTYPES.ProductRepository).toDynamicValue(createProductRepo).inSingletonScope(); + bind(IOCTYPES.ShipmentRepository).toDynamicValue(createShipmentRepository).inSingletonScope(); + bind(IOCTYPES.ProductVariantRepository).toDynamicValue(createProductVariantRepo).inSingletonScope(); + bind(IOCTYPES.ColorRepository).toDynamicValue(createColorRepo).inSingletonScope(); + bind(IOCTYPES.SizeRepository).toDynamicValue(createSizeRepo).inSingletonScope(); + bind(IOCTYPES.MeterageRepository).toDynamicValue(createMeterageRepo).inSingletonScope(); + bind(IOCTYPES.QuestionRepository).toDynamicValue(createQuestionRepo).inSingletonScope(); + bind(IOCTYPES.CommentRepository).toDynamicValue(createCommentRepo).inSingletonScope(); + bind(IOCTYPES.CartRepository).toDynamicValue(createCartRepo).inSingletonScope(); + bind(IOCTYPES.CartPaymentRepo).toDynamicValue(createCartPaymentRepo).inSingletonScope(); + bind(IOCTYPES.PRPaymentRepo).toDynamicValue(createPRPaymentRepo).inSingletonScope(); + bind(IOCTYPES.OrderRepository).toDynamicValue(createOrderRepo).inSingletonScope(); + bind(IOCTYPES.OrderItemRepo).toDynamicValue(createOrderItemRepo).inSingletonScope(); + bind(IOCTYPES.ProductRequestRepo).toDynamicValue(createProductRequestRepo).inSingletonScope(); + bind(IOCTYPES.PricingRepo).toDynamicValue(createPricingRepo).inSingletonScope(); + bind(IOCTYPES.CartShipItemRepo).toDynamicValue(createCartShipItemRepo).inSingletonScope(); + bind(IOCTYPES.PaymentMethodRepo).toDynamicValue(createPaymentMethodRepo).inSingletonScope(); + bind(IOCTYPES.CouponRepo).toDynamicValue(createCouponRepo).inSingletonScope(); + bind(IOCTYPES.CouponUsageRepo).toDynamicValue(createCouponUsageRepo).inSingletonScope(); + bind(IOCTYPES.SliderRepository).toDynamicValue(createSliderRepo).inSingletonScope(); + bind(IOCTYPES.BannerRepository).toDynamicValue(createBannerRepo).inSingletonScope(); + bind(IOCTYPES.ReportQuestionRepo).toDynamicValue(createReportQuestionRepo).inSingletonScope(); + bind(IOCTYPES.ContractRepository).toDynamicValue(createContractRepository).inSingletonScope(); + bind(IOCTYPES.ProductReportRepo).toDynamicValue(createProductReportRepo).inSingletonScope(); + bind(IOCTYPES.PriceHistoryRepo).toDynamicValue(createPriceHistoryRepo).inSingletonScope(); + bind(IOCTYPES.ProductAdRepo).toDynamicValue(createProductAdRepo).inSingletonScope(); + bind(IOCTYPES.PopularProductRepo).toDynamicValue(createPopularProductRepo).inSingletonScope(); + bind(IOCTYPES.IncredibleOffersRepo).toDynamicValue(createIncredibleOffersRepo).inSingletonScope(); + bind(IOCTYPES.ProductObserveRepo).toDynamicValue(createProductObserveRepo).inSingletonScope(); + bind(IOCTYPES.BlogRepository).toDynamicValue(createBlogRepo).inSingletonScope(); + bind(IOCTYPES.BlogCategoryRepo).toDynamicValue(createBlogCategoryRepo).inSingletonScope(); + bind(IOCTYPES.LearningRepo).toDynamicValue(CreateLearningRepo).inSingletonScope(); + bind(IOCTYPES.LearningProgressRepo).toDynamicValue(CreateLearningProgressRepo).inSingletonScope(); + bind(IOCTYPES.LearningCategoryRepo).toDynamicValue(CreateLearningCategoryRepo).inSingletonScope(); + bind(IOCTYPES.RoleRepository).toDynamicValue(createRoleRepo).inSingletonScope(); + bind(IOCTYPES.PermissionRepository).toDynamicValue(createPermissionRepo).inSingletonScope(); + bind(IOCTYPES.TicketRepository).toDynamicValue(createTicketRepo).inSingletonScope(); + bind(IOCTYPES.TicketMessageRepository).toDynamicValue(createTicketMessageRepo).inSingletonScope(); + bind(IOCTYPES.TicketCategoryRepository).toDynamicValue(createTicketCategoryRepo).inSingletonScope(); + bind(IOCTYPES.ChatRepository).toDynamicValue(createChatRepo).inSingletonScope(); + bind(IOCTYPES.ChatMessageRepository).toDynamicValue(createChatMessageRepo).inSingletonScope(); + bind(IOCTYPES.WalletRepo).toDynamicValue(createWalletRepo).inSingletonScope(); + bind(IOCTYPES.TransactionRepo).toDynamicValue(createTransactionRepo).inSingletonScope(); + bind(IOCTYPES.WithdrawalRepo).toDynamicValue(createWithdrawalRepo).inSingletonScope(); + bind(IOCTYPES.WishlistRepo).toDynamicValue(createWishlistRepo).inSingletonScope(); + bind(IOCTYPES.JobRepo).toDynamicValue(createJobRepo).inSingletonScope(); + bind(IOCTYPES.ResumeRepo).toDynamicValue(createResumeRepo).inSingletonScope(); + bind(IOCTYPES.FaqRepo).toDynamicValue(createFaqRepo).inSingletonScope(); + bind(IOCTYPES.NotificationRepo).toDynamicValue(createNotificationRepo).inSingletonScope(); + bind(IOCTYPES.ReturnOrderRepo).toDynamicValue(createReturnOrderRepo).inSingletonScope(); + bind(IOCTYPES.CancelOrderRepo).toDynamicValue(createCancelOrderRepo).inSingletonScope(); + bind(IOCTYPES.ReturnOrderItemRepo).toDynamicValue(createReturnOrderItemRepo).inSingletonScope(); + bind(IOCTYPES.CancelOrderItemRepo).toDynamicValue(createCancelOrderItemRepo).inSingletonScope(); + bind(IOCTYPES.ReturnReasonRepo).toDynamicValue(createReturnReasonRepo).inSingletonScope(); + bind(IOCTYPES.CancelReasonRepo).toDynamicValue(createCancelReasonRepo).inSingletonScope(); + bind(IOCTYPES.FineRuleRepo).toDynamicValue(createFineRuleRepo).inSingletonScope(); + bind(IOCTYPES.FineRepo).toDynamicValue(createFineRepo).inSingletonScope(); + bind(IOCTYPES.ShopRepo).toDynamicValue(createShopRepo).inSingletonScope(); + bind(IOCTYPES.WholesaleRequestRepo).toDynamicValue(createWholesaleRequestRepo).inSingletonScope(); + bind(IOCTYPES.AddressRepo).toDynamicValue(createAddressRepo).inSingletonScope(); + bind(IOCTYPES.CityRepo).toDynamicValue(createCityRepo).inSingletonScope(); + bind(IOCTYPES.ProvinceRepo).toDynamicValue(createProvinceRepo).inSingletonScope(); + bind(IOCTYPES.ContactUsRepo).toDynamicValue(CreateContactUsRepo).inSingletonScope(); + bind(IOCTYPES.AboutUsRepo).toDynamicValue(CreateAboutUsRepo).inSingletonScope(); + bind(IOCTYPES.SiteSettingRepo).toDynamicValue(CreateSiteSettingRepo).inSingletonScope(); + bind(IOCTYPES.NewsletterRepo).toDynamicValue(CreateNewsletterRepo).inSingletonScope(); + // #endregion +}); + +export { containerModules }; diff --git a/src/IOC/ioc.types.ts b/src/IOC/ioc.types.ts new file mode 100644 index 0000000..518b732 --- /dev/null +++ b/src/IOC/ioc.types.ts @@ -0,0 +1,136 @@ +export const IOCTYPES = { + // + CustomDecoratorMiddleware: Symbol.for("CustomDecoratorMiddleware"), + // #region service + PassportAuth: Symbol.for("PassportAuth"), + AuthService: Symbol.for("AuthService"), + CategoryService: Symbol.for("CategoryService"), + UserService: Symbol.for("UserService"), + AdminService: Symbol.for("AdminService"), + SellerService: Symbol.for("SellerService"), + SellerStatusRepo: Symbol.for("SellerStatusRepo"), + ProductService: Symbol.for("ProductService"), + SiteSettingService: Symbol.for("SiteSettingService"), + ProductRequestService: Symbol.for("ProductRequestService"), + ShipmentService: Symbol.for("ShipmentService"), + BrandService: Symbol.for("BrandService"), + WarrantyService: Symbol.for("WarrantyService"), + CacheService: Symbol.for("CacheService"), + RedisService: Symbol.for("RedisService"), + TokenService: Symbol.for("TokenService"), + CartService: Symbol.for("CartService"), + PaymentService: Symbol.for("PaymentService"), + PRPaymentService: Symbol.for("PRPaymentService"), + OrderService: Symbol.for("OrderService"), + CouponService: Symbol.for("CouponService"), + LandingService: Symbol.for("LandingService"), + BlogService: Symbol.for("BlogService"), + LearningService: Symbol.for("LearningService"), + TicketService: Symbol.for("TicketService"), + ChatGateway: Symbol.for("ChatGateway"), + ChatService: Symbol.for("ChatService"), + WsAuthService: Symbol.for("WsAuthService"), + WalletService: Symbol.for("WalletService"), + JobService: Symbol.for("JobService"), + FaqService: Symbol.for("FaqService"), + NotificationService: Symbol.for("NotificationService"), + ReturnService: Symbol.for("ReturnService"), + CancelService: Symbol.for("CancelService"), + FineService: Symbol.for("FineService"), + AddressService: Symbol.for("AddressService"), + ShopService: Symbol.for("ShopService"), + MediaService: Symbol.for("MediaService"), + ContactUsService: Symbol.for("ContactUsService"), + AboutUsService: Symbol.for("AboutUsService"), + NewsletterService: Symbol.for("NewsletterService"), + PricingService: Symbol.for("PricingService"), + OrderQueue: Symbol.for("OrderQueue"), + // #endregion + // #region repository + CategoryRepository: Symbol.for("CategoryRepository"), + CategoryAttributeRepository: Symbol.for("CategoryAttributeRepository"), + AttributeValueRepository: Symbol.for("AttributeValueRepository"), + UserRepository: Symbol.for("UserRepository"), + AdminRepository: Symbol.for("AdminRepository"), + SellerRepository: Symbol.for("SellerRepository"), + LegalSellerRepo: Symbol.for("LegalSellerRepo"), + RealSellerRepo: Symbol.for("RealSellerRepo"), + SellerContractRepo: Symbol.for("SellerContractRepo"), + SellerDocumentRepo: Symbol.for("SellerDocumentRepo"), + SellerFinancialRepo: Symbol.for("SellerFinancialRepo"), + DocumentTypeRepo: Symbol.for("DocumentTypeRepo"), + ProductRepository: Symbol.for("ProductRepository"), + ProductVariantRepository: Symbol.for("ProductVariantRepository"), + BrandRepository: Symbol.for("BrandRepository"), + WarrantyRepository: Symbol.for("WarrantyRepository"), + ShipmentRepository: Symbol.for("ShipmentRepository"), + TokenRepository: Symbol.for("TokenRepository"), + ColorRepository: Symbol.for("ColorRepository"), + SizeRepository: Symbol.for("SizeRepository"), + MeterageRepository: Symbol.for("MeterageRepository"), + QuestionRepository: Symbol.for("QuestionRepository"), + CommentRepository: Symbol.for("CommentRepository"), + ProductRequestRepo: Symbol.for("ProductRequestRepo"), + CartRepository: Symbol.for("CartRepository"), + CartShipItemRepo: Symbol.for("CartShipItemRepo"), + CartPaymentRepo: Symbol.for("CartPaymentRepo"), + PricingRepo: Symbol.for("PricingRepo"), + PRPaymentRepo: Symbol.for("PRPaymentRepo"), + PaymentMethodRepo: Symbol.for("PaymentMethodRepo"), + OrderRepository: Symbol.for("OrderRepository"), + OrderItemRepo: Symbol.for("OrderItemRepo"), + CouponRepo: Symbol.for("CouponRepo"), + CouponUsageRepo: Symbol.for("CouponUsageRepo"), + SliderRepository: Symbol.for("SliderRepository"), + BannerRepository: Symbol.for("BannerRepository"), + ProductReportRepo: Symbol.for("ProductReportRepo"), + ReportQuestionRepo: Symbol.for("ReportQuestionRepo"), + PriceHistoryRepo: Symbol.for("PriceHistoryRepo"), + ProductAdRepo: Symbol.for("ProductAdRepo"), + PopularProductRepo: Symbol.for("PopularProductRepo"), + IncredibleOffersRepo: Symbol.for("IncredibleOffersRepo"), + ProductObserveRepo: Symbol.for("ProductObserveRepo"), + BlogRepository: Symbol.for("BlogRepository"), + BlogCategoryRepo: Symbol.for("BlogCategoryRepo"), + LearningRepo: Symbol.for("LearningRepo"), + LearningProgressRepo: Symbol.for("LearningProgressRepo"), + LearningCategoryRepo: Symbol.for("LearningCategoryRepo"), + RoleRepository: Symbol.for("RoleRepository"), + PermissionRepository: Symbol.for("PermissionRepository"), + TicketRepository: Symbol.for("TicketRepository"), + TicketMessageRepository: Symbol.for("TicketMessageRepository"), + TicketCategoryRepository: Symbol.for("TicketCategoryRepository"), + ChatRepository: Symbol.for("ChatRepository"), + ChatMessageRepository: Symbol.for("ChatMessageRepository"), + WalletRepo: Symbol.for("WalletRepo"), + TransactionRepo: Symbol.for("TransactionRepo"), + WithdrawalRepo: Symbol.for("WithdrawalRepo"), + WishlistRepo: Symbol.for("WishlistRepo"), + JobRepo: Symbol.for("JobRepo"), + ResumeRepo: Symbol.for("ResumeRepo"), + FaqRepo: Symbol.for("FaqRepo"), + SiteSettingRepo: Symbol.for("SiteSettingRepo"), + NotificationRepo: Symbol.for("NotificationRepo"), + ReturnOrderRepo: Symbol.for("ReturnOrderRepo"), + CancelOrderRepo: Symbol.for("CancelOrderRepo"), + ContractRepository: Symbol.for("ContractRepo"), + ReturnOrderItemRepo: Symbol.for("ReturnOrderItemRepo"), + CancelOrderItemRepo: Symbol.for("CancelOrderItemRepo"), + ReturnReasonRepo: Symbol.for("ReturnReasonRepo"), + CancelReasonRepo: Symbol.for("CancelReasonRepo"), + FineRuleRepo: Symbol.for("FineRuleRepo"), + FineRepo: Symbol.for("FineRepo"), + ShopRepo: Symbol.for("ShopRepo"), + WholesaleRequestRepo: Symbol.for("WholesaleRequestRepo"), + AddressRepo: Symbol.for("AddressRepo"), + CityRepo: Symbol.for("CityRepo"), + ProvinceRepo: Symbol.for("ProvinceRepo"), + ContactUsRepo: Symbol.for("ContactUsRepo"), + AboutUsRepo: Symbol.for("AboutUsRepo"), + NewsletterRepo: Symbol.for("NewsletterRepo"), + // #endregion + Logger: Symbol.for("Logger"), + ZarinPalGateway: Symbol.for("ZarinPalGateway"), + AsanPardakhtGateway: Symbol.for("AsanPardakhtGateway"), + PaymentGateway: Symbol.for("PaymentGateway"), +}; diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..f05d625 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,159 @@ +import compression from "compression"; +import cors from "cors"; +import express, { Application } from "express"; +import { Container } from "inversify"; +import { InversifyExpressServer } from "inversify-express-utils"; +import morgan from "morgan"; + +import { errorHandler, lastHandler, notFoundHandler } from "./core/app/app.errorHandler"; +import { appConfig } from "./core/config/app.config"; +import { Logger } from "./core/logging/logger"; +import { customMorganFormat, stream } from "./core/logging/morgan"; +import { containerModules } from "./IOC/ioc.config"; +import { IOCTYPES } from "./IOC/ioc.types"; +import { PassportAuth } from "./utils/passport.service"; +import { setupSwagger } from "./utils/swagger"; + +//controller +import "./modules/admin"; +import "./modules/brand/brand.controller"; +import "./modules/warranty/warranty.controller"; +import "./modules/shipment/shipment.controller"; +import "./modules/category/category.controller"; +import "./modules/product/product.controller"; +import "./modules/seller/seller.controller"; +import "./modules/user/user.controller"; +import "./modules/cart/cart.controller"; +import "./modules/Coupon/coupon.controller"; +import "./modules/order/order.controller"; +import "./modules/return/return.controller"; +import "./modules/blog/blog.controller"; +import "./modules/landing/landing.controller"; +import "./modules/ticket/ticket.controller"; +import "./modules/chat/chat.controller"; +import "./modules/job/job.controller"; +import "./modules/faq/faq.controller"; +import "./modules/notification/notification.controller"; +import "./modules/wallet/wallet.controller"; +import "./modules/payment/payment.controller"; +import "./modules/address/address.controller"; +import "./modules/newsletter/newsletter.controller"; +import "./modules/contact-us/contactUs.controller"; +import "./modules/cancel/cancel.controller"; +import "./modules/about-us/aboutUs.controller"; +import "./modules/learning/learning.controller"; +import "./modules/fine/fine.controller"; +import "./modules/media/media.controller"; +import "./modules/site-setting/siteSetting.controller"; +import "./modules/shop/shop.controller"; +import "./modules/ping"; + +class App { + private readonly _container: Container; + // private readonly _port: number; + private logger = new Logger(); + + constructor(container: Container) { + this._container = container; + // this._port = port; + this.loadModules(); + } + + private async loadModules() { + await this._container.loadAsync(containerModules); + } + + public Build(): Application { + const server = new InversifyExpressServer(this._container, null /*,{ rootPath: "/api" }*/); + + return server + .setConfig(async (app) => { + await this.setMiddleware(app); + }) + .setErrorConfig((app) => { + this.catchError(app); + }) + .build(); + // .listen(this._port, () => { + // this.logger.info(`listening on http://localhost:${this._port}`); + // }); + } + + private async setMiddleware(app: Application) { + //remove x-powered-by header from response + app.disable("x-powered-by"); + this.logger.debug("disable the default x header of express"); + /** + * trust for reverse proxy to get correct ip address of user + * Configure based on deployment environment + */ + const trustedProxies = process.env.TRUSTED_PROXIES; + const trustProxyHops = process.env.TRUST_PROXY_HOPS; + + if (trustedProxies === "true") { + // recommended for production with multiple proxies/CDNs + app.set("trust proxy", 1); + this.logger.debug("trust proxy enabled for first hop only (production with CDN/proxies)"); + } else if (trustProxyHops && !isNaN(parseInt(trustProxyHops))) { + // Trust specific number of proxy hops + const hops = parseInt(trustProxyHops); + app.set("trust proxy", hops); + this.logger.debug(`trust proxy enabled for ${hops} hops`); + } else if (trustedProxies && trustedProxies !== "false") { + // Trust specific proxy IPs (comma-separated list) + const proxyList = trustedProxies.split(",").map((ip) => ip.trim()); + app.set("trust proxy", proxyList); + this.logger.debug(`trust proxy enabled for specific IPs: ${trustedProxies}`); + } else if (process.env.NODE_ENV === "development") { + // For development, allow localhost proxies + app.set("trust proxy", "loopback"); + this.logger.debug("trust proxy enabled for development (loopback only)"); + } else { + // Production without reverse proxy - don't trust any proxies + app.set("trust proxy", false); + this.logger.debug("trust proxy disabled for security"); + } + // app.get("/ip", (request, response) => response.send(request.ip)); + + /** + * compress the response + */ + app.use(compression()); + + /** + * configure body parser + */ + app.use(express.json()); + + /** + * configure morgan for http logging + */ + app.use(morgan(customMorganFormat, { stream })); + + /** + * configure cors + */ + app.use(cors(appConfig.cors)); + + /** + * config passport + */ + const passportAuth = this._container.get(IOCTYPES.PassportAuth); + app.use(passportAuth.initialize()); + passportAuth.plug(); + + /** + * swagger document + */ + setupSwagger(app); + this.logger.debug("setup swagger docs"); + } + + private catchError(app: Application) { + app.use(notFoundHandler()); + app.use(errorHandler()); + app.use(lastHandler()); + } +} + +export { App }; diff --git a/src/common/base/controller.ts b/src/common/base/controller.ts new file mode 100644 index 0000000..e019114 --- /dev/null +++ b/src/common/base/controller.ts @@ -0,0 +1,51 @@ +import { BaseHttpController } from "inversify-express-utils"; + +import { HttpStatus } from "../enums/httpStatus.enum"; +import { ResponseFactory } from "../factories/response.factory"; +import { IPageFormat } from "../interfaces/PageFormatInterface"; +import { IPaginateDataFormat } from "../interfaces/PaginateDataFormatInterface"; + +abstract class BaseController extends BaseHttpController { + private formatLink(page: number | boolean, limit: number): string | boolean { + const { protocol, hostname, path } = this.httpContext.request; + if (!page) return false; + return `${protocol}://${hostname}${path}?page=${page}&limit=${limit}`; + } + + protected formatPage(page: number, limit: number, totalItems: number): IPageFormat { + const totalPages = Math.ceil(totalItems / limit); + + const prevPage = page === 1 ? false : page - 1; + const nextPage = page >= totalPages ? false : page + 1; + + return { + page, + limit, + totalItems, + totalPages, + prevPage: this.formatLink(prevPage, limit), + nextPage: this.formatLink(nextPage, limit), + }; + } + + protected paginate(count: number): IPaginateDataFormat { + const page = this.httpContext.request.query["page"] as string; + const limit = this.httpContext.request.query["limit"] as string; + + const paginationInfo = this.formatPage(parseInt(page) || 1, parseInt(limit) || 10, count); + + const results: IPaginateDataFormat = { + pager: paginationInfo, + }; + + return results; + } + + protected response(data: Record, status: HttpStatus = HttpStatus.Ok) { + const response = ResponseFactory.successResponse(status, data); + + return this.json(response, status); + } +} + +export { BaseController }; diff --git a/src/common/base/repository.ts b/src/common/base/repository.ts new file mode 100644 index 0000000..be509ff --- /dev/null +++ b/src/common/base/repository.ts @@ -0,0 +1,23 @@ +import { Model } from "mongoose"; + +abstract class BaseRepository { + public model: Model; + + protected constructor(model: Model) { + this.model = model; + } + + async findById(id: string | number) { + return this.model.findById(id); + } + + async findAll() { + return this.model.find().sort({ createdAt: -1 }); + } + + async delete(id: string) { + return this.model.findByIdAndDelete(id); + } +} + +export { BaseRepository }; diff --git a/src/common/decorator/param.decorator.ts b/src/common/decorator/param.decorator.ts new file mode 100644 index 0000000..a4a06ac --- /dev/null +++ b/src/common/decorator/param.decorator.ts @@ -0,0 +1,19 @@ +import { interfaces } from "inversify-express-utils"; + +const USER_KEY = "custom:user"; + +// Middleware to resolve parameters for custom decorators +export function resolveCustomDecorators(httpContext: interfaces.HttpContext, target: any, propertyKey: string | symbol, args: any[]) { + const metadata = Reflect.getMetadata(USER_KEY, target, propertyKey) || []; + for (const { parameterIndex, callback } of metadata) { + args[parameterIndex] = callback(httpContext); + } +} + +export function createParamDecorator(callback: (httpContext: interfaces.HttpContext) => unknown): ParameterDecorator { + return (target, propertyKey, parameterIndex) => { + const existingMetadata = Reflect.getMetadata(USER_KEY, target, propertyKey as any) || []; + existingMetadata.push({ parameterIndex, callback }); + Reflect.defineMetadata(USER_KEY, existingMetadata, target, propertyKey as any); + }; +} diff --git a/src/common/decorator/swggerDocs.ts b/src/common/decorator/swggerDocs.ts new file mode 100644 index 0000000..3ba7f5d --- /dev/null +++ b/src/common/decorator/swggerDocs.ts @@ -0,0 +1,91 @@ +import "reflect-metadata"; + +// ApiOperation decorator +export function ApiOperation(summary: string): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:operation:summary", summary, target, propertyKey); + }; +} + +// ApiResponse decorator +export function ApiResponse(description: string, status: number = 200, example?: any): MethodDecorator { + return function (target, propertyKey) { + const existingResponses = Reflect.getMetadata("api:responses", target, propertyKey) || []; + existingResponses.push({ description, status, example }); + Reflect.defineMetadata("api:responses", existingResponses, target, propertyKey); + }; +} + +// ApiTags decorator +export function ApiTags(...tags: string[]): ClassDecorator { + return function (target) { + Reflect.defineMetadata("api:tags", tags, target); + }; +} + +// ApiParam decorator +export function ApiParam(name: string, description: string, required: boolean = false): MethodDecorator { + return function (target, propertyKey) { + const existingParams = Reflect.getMetadata("api:params", target, propertyKey) || []; + existingParams.push({ name, description, required }); + Reflect.defineMetadata("api:params", existingParams, target, propertyKey); + }; +} + +// ApiQuery decorator +export function ApiQuery( + name: string, + description: string, + required: boolean = false, + type: "string" | "array" = "string", +): MethodDecorator { + return function (target, propertyKey) { + const existingQueries = Reflect.getMetadata("api:queries", target, propertyKey) || []; + existingQueries.push({ name, description, required, type }); + Reflect.defineMetadata("api:queries", existingQueries, target, propertyKey); + }; +} + +// ApiBody decorator +export function ApiBody(description: string): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:body", description, target, propertyKey); + }; +} + +export function ApiModel(dto: any): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:body:dto", dto, target, propertyKey); + }; +} + +export interface ApiPropertyOptions { + type: string; + description?: string; + example?: any; + required?: boolean; +} + +export function ApiProperty(options: ApiPropertyOptions): PropertyDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:property", options, target, propertyKey); + }; +} + +export function ApiAuth(): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:auth", true, target, propertyKey); + }; +} + +export function ApiFile(description: string = "file", required: boolean = true, multiple: boolean = false): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:file", { description, required, multiple }, target, propertyKey); + }; +} + +export function AdminRoute(): MethodDecorator { + return function (target, propertyKey) { + Reflect.defineMetadata("api:isAdmin", true, target, propertyKey); + }; +} diff --git a/src/common/decorator/user.decorator.ts b/src/common/decorator/user.decorator.ts new file mode 100644 index 0000000..e1c16d2 --- /dev/null +++ b/src/common/decorator/user.decorator.ts @@ -0,0 +1,9 @@ +import "reflect-metadata"; +import { HttpContext } from "inversify-express-utils"; + +import { createParamDecorator } from "./param.decorator"; + +// export function User(): ParameterDecorator { +// return createParamDecorator((httpContext: interfaces.HttpContext) => httpContext.user); +// } +export const User = () => createParamDecorator((ctx: HttpContext) => ctx.request.user); diff --git a/src/common/decorator/validation.decorator.ts b/src/common/decorator/validation.decorator.ts new file mode 100644 index 0000000..893be58 --- /dev/null +++ b/src/common/decorator/validation.decorator.ts @@ -0,0 +1,128 @@ +import { ValidationArguments, ValidationOptions, registerDecorator } from "class-validator"; +import moment from "jalali-moment"; +import { Model, isValidObjectId } from "mongoose"; + +import { CommonMessage } from "../enums/message.enum"; + +// @ValidatorConstraint({ async: true }) +// export class IsTypeValidConstraint implements ValidatorConstraintInterface { +// // eslint-disable-next-line no-unused-vars +// async validate(type: string, _args: ValidationArguments) { +// return type === process.env.USER_TYPE || type === process.env.SELLER_TYPE; +// } +// // eslint-disable-next-line no-unused-vars +// defaultMessage(_args: ValidationArguments) { +// return AuthMessage.TypeError; +// } +// } + +// export function IsTypeValid(validationOptions?: ValidationOptions) { +// return function (object: object, propertyName: string) { +// registerDecorator({ +// target: object.constructor, +// propertyName: propertyName, +// options: validationOptions, +// constraints: [], +// validator: IsTypeValidConstraint, +// }); +// }; +// } +// export function IsPhoneExists(model: Model, userId?: string, validationOptions?: ValidationOptions) { +// return function (object: object, propertyName: string) { +// registerDecorator({ +// name: "IsPhoneExists", +// target: object.constructor, +// propertyName: propertyName, +// options: validationOptions, +// constraints: [userId], +// validator: { +// async validate(phoneNumber: string, args: ValidationArguments) { +// const [userId] = args.constraints; +// const user = await model.exists({ phoneNumber, _id: { $ne: userId } }); +// return !user; // if user exists with a different ID, return false (validation fails) +// }, +// // eslint-disable-next-line no-unused-vars +// defaultMessage(_args: ValidationArguments) { +// return AuthMessage.NumberExists; +// }, +// }, +// }); +// }; +// } + +// export function IsEmailExists(model: Model, userId?: string, validationOptions?: ValidationOptions) { +// return function (object: object, propertyName: string) { +// registerDecorator({ +// name: "IsEmailExists", +// target: object.constructor, +// propertyName: propertyName, +// options: validationOptions, +// constraints: [userId], +// validator: { +// async validate(email: string, args: ValidationArguments) { +// const [userId] = args.constraints; +// const user = await model.exists({ email, _id: { $ne: userId } }); +// return !user; // if user exists with a different ID, return false (validation fails) +// }, +// // eslint-disable-next-line no-unused-vars +// defaultMessage(_args: ValidationArguments) { +// return AuthMessage.EmailExists; +// }, +// }, +// }); +// }; +// } + +export function IsValidId(model: Model | Array>, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "IsValidId", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [model], + validator: { + // eslint-disable-next-line no-unused-vars + async validate(id: string | number, _args: ValidationArguments) { + if (typeof id === "string") { + if (!isValidObjectId(id)) return false; + } + + // Normalize to an array for uniform processing + const models = Array.isArray(model) ? model : [model]; + + // Check the ID in each model + for (const m of models) { + const existId = await m.exists({ _id: id }); + if (existId) return true; // ID exists in one of the models + } + + return false; // ID does not exist in any of the models + }, + defaultMessage(args: ValidationArguments) { + return `${CommonMessage.NotValidId} ${args.property}`; + }, + }, + }); + }; +} + +export function IsValidPersianDate(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "IsValidPersianDate", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: { + validate(date: string) { + return moment(date, "jYYYY/jMM/jDD", true).isValid(); + }, + defaultMessage() { + return "Invalid Persian date format. Expected format is YYYY/MM/DD."; + }, + }, + }); + }; +} diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..2a778f7 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -0,0 +1,19 @@ +import { Expose } from "class-transformer"; +import { IsInt, IsNotEmpty, IsOptional, Max, Min } from "class-validator"; + +export class PaginationDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsInt() + @Min(1) + page?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/common/dto/param.dto.ts b/src/common/dto/param.dto.ts new file mode 100644 index 0000000..b8f4a64 --- /dev/null +++ b/src/common/dto/param.dto.ts @@ -0,0 +1,11 @@ +import { Expose } from "class-transformer"; +import { IsMongoId, IsNotEmpty } from "class-validator"; + +import { CommonMessage } from "../enums/message.enum"; + +export class ParamDto { + @Expose() + @IsNotEmpty() + @IsMongoId({ message: CommonMessage.NotValidId }) + id: string; +} diff --git a/src/common/enums/category.enum.ts b/src/common/enums/category.enum.ts new file mode 100644 index 0000000..d603808 --- /dev/null +++ b/src/common/enums/category.enum.ts @@ -0,0 +1,14 @@ +export enum CategoryThemeEnum { + Sized = "Size", + Colored = "Color", + Meterage = "Meterage", + No_color_No_sized = "noColor_noSize", +} + +export enum categoryAttType { + Text = "text", + Select = "select", + Number = "number", + CheckBox = "checkbox", + // Input = "input", +} diff --git a/src/common/enums/httpMethod.enum.ts b/src/common/enums/httpMethod.enum.ts new file mode 100644 index 0000000..1de44e3 --- /dev/null +++ b/src/common/enums/httpMethod.enum.ts @@ -0,0 +1,7 @@ +export enum HttpMethod { + Post = "POST", + Get = "GET", + Patch = "PATCH", + Put = "PUT", + Delete = "DELETE", +} diff --git a/src/common/enums/httpStatus.enum.ts b/src/common/enums/httpStatus.enum.ts new file mode 100644 index 0000000..e688d7d --- /dev/null +++ b/src/common/enums/httpStatus.enum.ts @@ -0,0 +1,13 @@ +export const enum HttpStatus { + Ok = 200, + Created = 201, + Accepted = 202, + NoContent = 204, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + Conflict = 409, + PayloadTooLarge = 413, + InternalServerError = 500, +} diff --git a/src/common/enums/media.enum.ts b/src/common/enums/media.enum.ts new file mode 100644 index 0000000..201fd35 --- /dev/null +++ b/src/common/enums/media.enum.ts @@ -0,0 +1,9 @@ +export enum DisplayLocations { + Mobile = "mobile", + Desktop = "desktop", +} + +export enum MediaType { + Banner = "banner", + Slider = "slider", +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts new file mode 100644 index 0000000..011ad83 --- /dev/null +++ b/src/common/enums/message.enum.ts @@ -0,0 +1,325 @@ +export const enum AuthMessage { + //otp + OtpSentToNo = "کد به شماره موبایل شما ارسال شد", + OtpNotEmpty = "کد نباید خالی باشد", + OtpAlreadySentToNo = "کد به شماره موبایل شما ارسال شده است", + OtpAlreadySentToEmail = "کد به ایمیل شما ارسال شده است لطفا ایمیل خود را چک کنید یا بعد از چند دقیقه مجدد تلاش کنید", + OtpSentToEmail = "کد به ایمیل شما ارسال شد", + EmailSent = "ایمیل فعال سازی برای شما ارسال شد", + OtpIncorrect = "کد صحیح نمی باشد", + OtpCodeLength = "طول کد باید حداقل ۵ و حداکثر ۵ کاراکتر باشد", + //login/logout/auth + SuccessLogin = "با موفقیت وارد شدید", + PasswordChangedSuccessfully = "پسورد با موفقیت تغییر کرد", + EmailOrPasswordInc = "ایمیل یا پسورد اشتباه است", + ShopNotFound = "فروشگاه با این ایدی پیدا نشد", + IncorrectNumber = "فرمت موبایل اشتباه است", + UserIsLoggedIn = "شما قبلا وارد شدید", + LoggedOut = "شما از سیستم خارج شدید", + NeedLogin = "لطفا وارد حساب کاربری خود شوید", + EmailOrPhone = "یکی از ایمیل یا موبایل باید ارسال شود", + NumberNotEmpty = "شماره موبایل نباید خالی باشد", + TypeNotEmpty = "نوع کاربر نباید خالی باشد", + IncorrectEmail = "فرمت ایمیل اشتباه است", + EmailNotEmpty = "ایمیل نباید خالی باشد", + PasswordNotEmpty = "پسورد نباید خالی باشد", + NumberExists = "این شماره قبلا استفاده شده است", + EmailExists = "این ایمیل قبلا استفاده شده است", + TypeError = "تایپ اشتباه می باشد", + //token + TokenNotEmpty = "توکن نباید خالی باشد", + TokenNotFound = "توکن پیدا نشد", + TokenExpired = " توکن منقضی شده است لطفا دوباره وارد شوید", + TokenNotAssigned = " توکن به هیج کاربری متعلق نمی باشد دوباره وارد شوید", + AuthFailed = "کاربر احراز هویت نشده است", + InsufficientRole = "رول کاربر مجاز نیست", + InsufficientPermissions = "دسترسی‌های کاربر کافی نیست", +} + +export const enum SellerMessage { + ShopUpdated = "اطلاعات فروشگاه با موفقیت آپدیت شد", + ShopIdIncorrect = "فرمت شناسه ملی شرکت اشتباه است باید 11 رقم و بدون علامت اضافی و به صورت رشته ارسال شود", + SellerNotFound = "فروشنده یافت نشد", + VideoWasWached = "قبلا این ویدیو را مشاهده کرده اید", + CardNumberIncorrect = "فرمت شماره کارت اشتباه است باید 16 رقم و بدون علامت اضافی و به صورت رشته ارسال شود", + CompanyDuplicate = "مشخصات شرکت تکراری می باشد", + RealSellerDuplicate = "مشخصات فروشنده تکراری می باشد", + SellerDocumentNotFound = "مدرک فروشنده یافت نشد", + DocumentTypeDuplicate = "نوع مدرک تکراری می باشد", + WholesaleRequestPending = "درخواست عمده فروشی شما در حال بررسی می باشد", + WholesaleRequestCreated = "درخواست عمده فروشی با موفقیت ثبت شد", + StatusNotFound = "وضعیت فروشنده یافت نشد", + WholesaleRequestNotFound = "درخواست عمده فروشی یافت نشد", + WholesaleRequestApproved = "درخواست عمده فروشی تایید شد", + InvalidBusinessType = "نوع فعالیت اشتباه است", +} + +export const enum UserMessage { + UserUpdated = "اطلاعات کاربر با موفقیت آپدیت شد", + EmailNotExist = "ایمیل کاربر ثبت نشده است", + UserNotFound = "کاربری با این ایدی یافت نشد", +} + +export const enum CommonMessage { + NotFoundBySlug = "با این اسلاگ پیدا نشد", + Created = "با موفقیت اضافه شد", + NameNotEmpty = "اسم نباید خالی باشد", + NationalCodeIncorrect = " فرمت کد ملی اشتباه است باید 10 رقم و بدون علامت اضافی و به صورت رشته ارسال شود", + PostalIncorrect = "فرمت شماره پستی اشتباه است باید 10 رقم و بدون علامت اضافی و به صورت رشته ارسال شود", + ShebaIncorrect = "فرمت شماره شبا اشتباه است باید 24 رقم و بدون علامت اضافی و به صورت رشته ارسال شود", + TelephoneNumberIncorrect = "فرمت شماره تلفن اشتباه است نباید شامل کاراکتر های خاص باشد", + NotFound = "کاربری با این ایدی پیدا نشد", + NotValid = "آیدی کاربر صحیح نمی باشد", + NotValidId = "آیدی صحیح نمی باشد", + NotEnoughVariant = "محصول تنوعی ندارد", + NotFoundById = "دیتا با این آیدی یافت نشد", + Deleted = "با موفقیت حذف شد", + Updated = "با موفقیت آپدیت شد", + DuplicateName = "نام تکراری می باشد", + SellerNotFound = "فروشنده یافت نشد", + SettingExists = "تنظیمات وجود دارد.در صورت نیاز آنرا ویرایش کنید.", +} + +export const enum CategoryMessage { + Created = "دسته بندی با موفقیت ساخته شد", + Deleted = "دسته بندی با موفقیت حذف شد.", + Updated = "دسته بندی با موفقیت اپدیت شد.", + ParentNotExist = "کتگوری والد با این ایدی وجود ندارد", + description = "توضیحات را وارد کنید", + DuplicateTitle = "عنوان انگلیسی تکراری می باشد", + DuplicateSlug = "عنوان اسلاگ تکراری می باشد", + AlreadyExist = "تنوع قبلا ساخته شده است", + AttAlreadyExist = "ویژگی‌های این دسته بندی قبلا ساخته شده است", + VariantCreated = "تنوع‌های دسته بندی با موفقیت ساخته شدند", + AttributeCreated = "ویژگی‌های دسته بندی با موفقیت ساخته شدند", + NotValidId = "آیدی کتگوری صحیح نمی باشد", + MissingColors = "داده‌های رنگ برای تم انتخاب شده موجود نیست.", + MissingSizes = "داده‌های سایز برای تم انتخاب شده موجود نیست.", + MissingMeterages = "داده‌های متراژ برای تم انتخاب شده موجود نیست.", + InvalidTheme = "ویژگی‌های ارسال شده با تم انتخاب شده مطابقت ندارند یا ناقص هستند.", + NotFound = "کتگوری با این نام پیدا نشد", + ParentHasChild = "فرزند این دسته بندی قبلا ساخته شده است", + CanNotDelete = "دسته بندی نمیتواند حذف شود", + CategoryHasProduct = "این دسته بندی دارای محصول می باشد و قابل حذف نمی باشد", + CategoryIsParent = "این دسته بندی دارای زیر دسته می باشد و قابل حذف نمی باشد", + CategoryNotValidToChose = "کتگوری برای انتخاب مجاز نمی‌باشد", + AttributeDeleted = "ویژگی با موفقیت حذف شد", +} + +export const enum BrandMessage { + NotFound = "برندی با این اسم یافت نشد", + Exist = "برند با این نام وجود دارد", + Created = "برند جدید با موفقیت ساخته شد", +} + +export const enum ProductMessage { + Created = "کالا با موفقیت ساخته شد", + ProductRequested = "درخواست اضافه کردن کالا با موفقیت ثبت شد", + NotFound = "کالایی با این ایدی یافت نشد", + CreatedAttribute = "مشخصات کالا با موفقیت ساخته شد", + DuplicateName = "نام مدل تکراری می باشد", + ProductNotFound = "محصول با این ایدی یافت نشد", + ProductVariantNotFound = "تنوع محصول با این ایدی یافت نشد", + CanNotUpdate = "این کالا متعلق به فروشنده نمی باشد", + CanNotDeleteQuestion = "حذف پرسش با مشکل مواجه شد.", + CanNotDeleteComment = "حذف کامنت با مشکل مواجه شد.", + ProductReadyForRelease = "کالا ثبت شد و آماده‌ی انتشار می باشد", + ProductIsInNextStep = "کالا قبلا این مرحله را گذرانده است", + ProductIsInDraftStep = "کالا در حالت انتشار نمی‌باشد", + VariantCreated = "تنوع با موفقیت ساخته شد", + VariantUpdated = "تنوع با موفقیت آپدیت شد", + Deleted = "محصول و تمامی تنوع های آن با موفقیت حذف شدند.", + CanNotDelete = "محصول قابل حذف نمی باشد", + CanNotUpdateProduct = "محصول قابل اپدیت نمی باشد", + ProductMustBeInDraft = "کالا برای اپدیت باید در حالت پیش نویس باشد", + ProductUpdated = "محصول با موفقیت آپدیت شد", + ProductWithNoVariantCategoryTheme = "کالا‌هایی که دسته بندی آن ها بدون تنوع می باشد بیشتر از یک تنوع نمی توانند داشته باشند", + Cloned = "کالای مورد نظر کلون شد", + VariantAlreadyExists = "تنوع محصول قبلا ساخته شده است", +} + +export const enum ProductRequestMessage { + RequestPhotoInvalid = "برای درج درخواست عکاسی باید تعداد عکس هم ارسال شود", + PhotoShouldSent = "درصورت دارا بودن عکس باید آن را ارسال کنید", + NotFound = "درخواستی برای این فروشنده با این ایدی یافت نشد", +} +export const enum WarrantyMessage { + Created = "گارانتی با موفقیت ساخته شد", + DuplicateName = "نام تکراری می باشد", +} + +export const enum ContractMessage { + Created = "قرارداد با موفقیت ساخته شد", + NotFound = "قراردادی با این نام پیدا نشد", + AlreadyExist = "قرارداد قبلا ساخته شده است", + ContractNotFound = "قراردادی پیدا نشد", + ContractAlreadySigned = "قرارداد قبلا امضا شده است", +} + +export const enum AttributeMessage { + AttributeNotFound = "ویژگی دسته‌بندی یافت نشد.", + AttributeIdIsIncorrect = "آیدی ویژگی صحیح نمی باشد.", + ColorNotAllowedForSizedTheme = "استفاده از ویژگی رنگ یا متراژ برای دسته‌بندی با تم سایز مجاز نیست.", + SizeNotAllowedForColoredTheme = "استفاده از ویژگی سایز یا متراژ برای دسته‌بندی با تم رنگ مجاز نیست.", + ColorRequiredForColoredTheme = "ویژگی رنگ برای دسته‌بندی با تم رنگ الزامی است.", + SizeRequiredForSizedTheme = "ویژگی سایز برای دسته‌بندی با تم سایز الزامی است.", + MeterageRequiredForMeterageTheme = "ویژگی متراژ برای دسته‌بندی با تم متراژ الزامی است.", + ColorOrSizeNotAllowedForMeterageTheme = "استفاده از ویژگی رنگ یا سایز برای دسته‌بندی با تم متراژ مجاز نیست.", + UnknownCategoryTheme = "تم دسته‌بندی نامشخص است.", + ThemeIsNoColorNoSize = "تم دسته‌بندی بدون رنگ و سایز می‌باشد.", + ThemeIsNoColorNoSizeNoMeterage = "تم دسته‌بندی بدون رنگ، سایز و متراژ می‌باشد.", +} +export const enum CartMessage { + StockNotEnough = "مقدار درخواستی از موجودی کالا بیشتر است", + CartNotFound = "سبد خرید برای این کاربر یافت نشد", + OrderLimitExceeded = "مقدار درخواستی از حد سفارش کالا بیشتر است", + CartIdInvalid = "سبد خرید با این آیدی یافت نشد", + ItemNotFound = "این کالا در سبد خرید موجود نیست", + ItemAdded = "کالا با موفقیت به سبد خرید اضافه شد", + ItemDeleted = "کالا با موفقیت از سبد خرید حذف شد", + CartUpdated = "سبد خرید با موفقیت بروز شد", + ProductNotBelongToVariant = "این تنوع مربوط به این کالا نیست", + CartShipmentItemsNotFound = "آیتم های سبد خرید یافت نشد", + InvalidCartShipmentItem = "آیتم سبد خرید معتبر نیست", +} + +export const enum PaymentMessage { + PaymentNotFound = "پرداخت با این شناسه پیدا نشد", + PaymentMethodNotFound = "درگاه پرداخت با این شناسه پیدا نشد", + PaymentMethodsNotFound = "درگاه پرداختی پیدا نشد", + PaymentNotSuccessful = "پرداخت موفقیت آمیز نبوده‌ است", + PaymentSuccessful = "پرداخت آمیز با موفقیت انجام شد", + PRPaymentDesc = "پرداخت برای درخواست ثبت کالا", + CartPaymentDesc = "خرید کالا:", + PaymentNotBelongToUser = "پرداخت برای این کاربر نمی‌باشد", + PaymentRequestFailed = "درخواست پرداخت موفقیت آمیز نبود", +} + +export const enum CouponMessage { + NotFound = "کد تخفیف اشتباه است یا غیرفعال شده است", + Expired = "کد تخفیف منقضی شده است", + AmountIsNotSatisfied = "مبلغ خرید برای اعمال کد تخفیف کافی نیست", + UsageLimit = "تعداد استفاده از کد تخفیف به اتمام رسیده است", + CouponUsedBefore = " شما قبلا از این کد تخفیف استفاده کرده‌اید", + CouponAdded = "کد تخفیف اعمال شد", + CouponAlreadyInCart = "کد تخفیف فقط یکبار می‌تواند در هر سبد خرید استفاده شود", + InvalidId = "آیدی کد تخفیف صحیح نمی باشد", + DiscountAmountOrPercentageRequired = "باید یکی از مقادیر مبلغ تخفیف یا درصد را وارد کنید", + BothDiscountOrPercentageShouldNotSend = "نباید هر دو مقدار مبلغ تخفیف و درصد را وارد کنید", + UserCouponUsageLimit = "تعداد استفاده شما از کد تخفیف به اتمام رسیده است", + InvalidProduct = "این کد تخفیف برای این کالا معتبر نمی‌باشد", + InvalidCategory = "این کد تخفیف برای این دسته بندی معتبر نمی‌باشد", + BothIncludedAndExcludedProductsShouldNotSend = "نباید هر دو مقدار محصولات شامل و محصولات مستثنی را وارد کنید", +} + +export const enum MediaMessage { + MediaCreated = "مدیا ساخته شد", + SliderCreated = "اسلایدر ساخته شد", + BannerCreated = "بنر ساخته شد", + Activated = "مدیا فعال شد", + UploadLimitSize = "حجم فایل باید کمتر از 6 مگابایت باشد", +} + +export const enum SetShipmentMessage { + CartNotFound = "سبد خرید یافت نشد", + InvalidShipmentInfoLength = "باید برای همه اقلام موجود در سبد خرید ارسال‌کننده مشخص کنید", + ProductVariantNotFound = "تنوع محصول یافت نشد", + ShipperNotAvailableForSeller = "این ارسال‌کننده برای این فروشنده موجود نیست", + ShipmentProviderNotFound = "ارائه‌دهنده خدمات ارسال یافت نشد", + CartItemNotFound = "کالای مربوط به این نوع محصول در سبد خرید یافت نشد", + InvalidShipmentItems = "برخی از تنوع‌های ارسال شده معتبر نیستند", + InvalidShipmentSellers = "برای این خرید همچین فروشنده‌ای وجود ندارد", + ShipmentOptionsUnavailable = "روش ارسال برای این کالا موجود نیست", + ShipmentMethodAlreadyActive = "این روش ارسال قبلا انتخاب شده است", + ShipmentMethodNotActive = "این روش ارسال انتخاب نشده است", +} + +export const enum AdMessage { + NotFoundWithId = "تبلیغی با این پیدا نشد", +} + +export const enum OrderMessage { + NotFound = "سفارشی با این آیدی پیدا نشد", + TrackingDetailInserted = "مشخصات مرسوله با موفقیت ثبت شد", + OrderItemsNotFound = "آیتم های این سفارش یافت نشد", + ShipmentItemNotFound = "آیتم های ارسالی سفارش یافت نشد", + ReturnOrderNotFound = "سفارش برگشتی با این آیدی پیدا نشد", + ReturnOrderItemsNotFound = "آیتم های سفارش برگشتی پیدا نشد", + ReturnApproved = "مرجوعی کالا تایید شد", + ReturnRejected = "مرجوعی کالا رد شد", + ReturnCompleted = "فرایند مرجوعی کامل شد", + ItemReceived = "وضعیت سفارش شما برای این فروشنده به دریافت شده تغییر پیدا کرد", + TrackingCodeNotFound = "برای سفارش شما کد پیگیری یافت نشد", + TrackingEmailSent = "ایمیل حاوی اطلاعات سفارس و کد رهگیری برای شما ارسال شد", + ItemAlreadyReceived = "سفارش شما قبلا تحویل گرفته شده است", + ItemNotShipped = "سفارش هنوز ارسال نشده است", +} + +export const enum TicketMessage { + TicketNotBelongToSeller = "این تیکت متعلق به فروشنده نمی‌باشد", + TicketNotFound = "تیکتی با این آیدی پیدا نشد", +} + +export const enum ChatMessage { + ChatNotFound = "چتی با این آیدی پیدا نشد", +} + +export const enum WalletMessage { + WalletNotFound = "کیف پول فروشنده یافت نشد", + CreditedSuccessfully = "کیف پول با موفقیت شارژ شد", + InsufficientBalance = "موجودی کیف پول کافی نمی‌باشد", + InsufficientBalanceForWithdrawal = "موجودی کیف پول برای برداشت کافی نمی‌باشد", + DebitedSuccessfully = "برداشت از کیف پول با موفقیت انجام شد", + WithdrawalNotFound = "درخواست برداشتی با این آیدی یافت نشد", + WithdrawalFailed = "برداشت از کیف پول با مشکل مواجه شد", + WithdrawalSubmitted = "درخواست برداشت از کیف پول ثبت شد", + CreditFailed = "شارژ کیف پول ناموفق بود", + InvalidTransaction = "تراکنش نامعتبر است", + RejectWithdrawalRequest = "درخواست برداشت از کیف پول رد شد", +} + +export const enum ShopMessage { + ShopNotFound = "فروشگاه برای این فروشنده یافت نشد", + ShopNotFoundById = "با این ایدی فروشگاه یافت نشد", + ShopNameExist = "نام فروشگاه تکراری می باشد", + NotFound = "فروشگاه یافت نشد", +} + +export const enum AddressMessage { + Updated = "آدرس با موفقیت آپدیت شد", + NotFound = "آدرس کاربر یافت نشد", + UserShouldHaveAddress = "برای ثبت سفارش .کاربر ملزم به ثبت آدرس می‌باشد", + InvalidLocation = "مختصات جغرافیایی اشتباه می باشد", + SellerAddressNotFound = "برای ثبت قرار داد ابتدا آدرس خود در قسمت پروفابل ثبت کنید", +} + +export const enum PricingMessage { + NotFound = "قیمت ها بافت نشد", + PhotographyFee = "هزینه عکاسی", + InsertFeeNotFound = "هزینه درج محصول یافت نشد", + UnboxingVideoFee = "هزینه ویدیو انباکسینگ یافت نشد", + ExpertReviewsFeeNotFound = "هزینه نقد و بررسی یافت نشد", + expertReviewsFee = "هزینه نقد و بررسی", + TypeIsExist = "این نوع قبلا ثبت شده است", +} + +export const enum AdminMessage { + FullNameNotEmpty = "نام و نام خانوادگی نباید خالی باشد", + UserNameNotEmpty = "نام کاربری نباید خالی باشد", + RoleNotEmpty = "نقش کاربر نباید خالی باشد", + PasswordNotEmpty = "پسورد نباید خالی باشد", + AdminIsExist = "این نام کاربری قبلا ثبت شده است", + RoleNotFound = "نقش ادمین یافت نشد", + EmailNotEmpty = "ایمیل نباید خالی باشد", + PhoneNotEmpty = "شماره موبایل نباید خالی باشد", + DeletedSuccessfully = "حذف با موفقیت انجام شد", + NotFound = "ادمین یافت نشد", +} + +export const enum RoleMessage { + RoleNotFound = "نقش یافت نشد", + RoleNameNotEmpty = "نام نقش نباید خالی باشد", + RoleNameDuplicate = "نام نقش تکراری می باشد", + PermissionsNotEmpty = "دسترسی‌ها نباید خالی باشد", + RoleExist = "این نقش قبلا ثبت شده است", +} diff --git a/src/common/enums/order.enum.ts b/src/common/enums/order.enum.ts new file mode 100644 index 0000000..7e91fef --- /dev/null +++ b/src/common/enums/order.enum.ts @@ -0,0 +1,22 @@ +export enum PaymentStatus { + Pending = "Pending", + Cancelled = "Cancelled", + Completed = "Completed", +} +export enum OrdersStatus { + wait_payment = "wait_payment", + process_by_seller = "process_by_sellers", + cancelled_system = "cancelled_system", + Cancelled = "cancelled", + Delivered = "Delivered", +} + +export enum OrderItemsStatus { + Processing = "Processing", + Shipped = "Shipped", + Delivered = "Delivered", + cancelled_shop = "cancelled_shop", + cancelled_system = "cancelled_system", + cancelled_user = "cancelled_user", + Returned = "Returned", +} diff --git a/src/common/enums/page.enum.ts b/src/common/enums/page.enum.ts new file mode 100644 index 0000000..94ab8d4 --- /dev/null +++ b/src/common/enums/page.enum.ts @@ -0,0 +1,9 @@ +export enum PageEnum { + Faq = "Faq", + Seller = "Seller", + ProductTag = "ProductTag", + ProductDescription = "ProductDescription", + ProductBenefit = "ProductBenefit", + ShipmentProcess = "ShipmentProcess", + Policy = "Policy", +} diff --git a/src/common/enums/payment.enum.ts b/src/common/enums/payment.enum.ts new file mode 100644 index 0000000..6799f3e --- /dev/null +++ b/src/common/enums/payment.enum.ts @@ -0,0 +1,10 @@ +export enum GatewayProvider { + Zarinpal = "Zarinpal", + Asanpardakht = "asanPardakht", +} + +export enum PaymentMethodType { + Online = "online", + POS = "pos", + Wallet = "wallet", +} diff --git a/src/common/enums/product.enum.ts b/src/common/enums/product.enum.ts new file mode 100644 index 0000000..492031f --- /dev/null +++ b/src/common/enums/product.enum.ts @@ -0,0 +1,28 @@ +export enum ProductMarketStatus { + Out_of_stock = "out_of_stock", + Marketable = "Marketable", + stop_production = "stopProduction", +} + +export enum ProductStatus { + Draft = "Draft", + Pending = "Pending", + Approved = "Approved", + Rejected = "Rejected", +} + +export enum ProductSource { + LOCAL = "local", + IMPORT = "import", +} + +export enum ProductDiscountType { + Fixed = "fixed", + Percent = "percent", +} + +export enum CreateProductStep { + Detail = 1, + Attribute, + Image, +} diff --git a/src/common/enums/question_comment.enum.ts b/src/common/enums/question_comment.enum.ts new file mode 100644 index 0000000..c8ef138 --- /dev/null +++ b/src/common/enums/question_comment.enum.ts @@ -0,0 +1,5 @@ +export enum Question_CommentStatus { + Pending = "pending", + Accepted = "accepted", + Rejected = "rejected", +} diff --git a/src/common/enums/seller.enum.ts b/src/common/enums/seller.enum.ts new file mode 100644 index 0000000..11d9d1b --- /dev/null +++ b/src/common/enums/seller.enum.ts @@ -0,0 +1,9 @@ +export enum SellerType { + WHOLESALER = "WHOLESALER", + RETAILER = "RETAILER", +} + +export enum SellerGender { + Male = "MALE", + Female = "FEMALE", +} diff --git a/src/common/enums/shipment.enum.ts b/src/common/enums/shipment.enum.ts new file mode 100644 index 0000000..45f9aec --- /dev/null +++ b/src/common/enums/shipment.enum.ts @@ -0,0 +1,6 @@ +// Enum for Delivery Types +export enum DeliveryType { + SameDay = "SameDay", //(1) day + Express = "Express", //(1-2) days + Standard = "Standard", //(3-5) days +} diff --git a/src/common/enums/status.enum.ts b/src/common/enums/status.enum.ts new file mode 100644 index 0000000..7d2b1bf --- /dev/null +++ b/src/common/enums/status.enum.ts @@ -0,0 +1,6 @@ +export enum StatusEnum { + Pending = "Pending", + Approved = "Approved", + Completed = "Completed", + Rejected = "Rejected", +} diff --git a/src/common/factories/response.factory.ts b/src/common/factories/response.factory.ts new file mode 100644 index 0000000..dc51e3a --- /dev/null +++ b/src/common/factories/response.factory.ts @@ -0,0 +1,24 @@ +import { HttpStatus } from "../enums/httpStatus.enum"; +import { IErrorResponse, ISuccessResponse } from "../interfaces/IAppResponse"; + +export class ResponseFactory { + public static successResponse(status: HttpStatus, data: Record): ISuccessResponse { + return { + status, + success: true, + results: { + ...data, + }, + }; + } + public static errorResponse(status: HttpStatus, message: string, details: string[] = []): IErrorResponse { + return { + status, + success: false, + error: { + message, + details, + }, + }; + } +} diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..07bf00e --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1,8 @@ +//interfaces +export { IAppOptions } from "./interfaces/IAppOptions"; +export { IErrorResponse, ISuccessResponse } from "./interfaces/IAppResponse"; +//enums +export { HttpStatus } from "./enums/httpStatus.enum"; +export { HttpMethod } from "./enums/httpMethod.enum"; +//factories +export { ResponseFactory } from "./factories/response.factory"; diff --git a/src/common/interfaces/IAppOptions.ts b/src/common/interfaces/IAppOptions.ts new file mode 100644 index 0000000..bd19e1e --- /dev/null +++ b/src/common/interfaces/IAppOptions.ts @@ -0,0 +1,7 @@ +import { CorsOptions } from "cors"; +import { Options } from "express-rate-limit"; + +export interface IAppOptions { + cors: CorsOptions; + rate: Partial; +} diff --git a/src/common/interfaces/IAppResponse.ts b/src/common/interfaces/IAppResponse.ts new file mode 100644 index 0000000..29ccf91 --- /dev/null +++ b/src/common/interfaces/IAppResponse.ts @@ -0,0 +1,16 @@ +import { HttpStatus } from "../enums/httpStatus.enum"; + +export interface IErrorResponse { + status: HttpStatus; + success: boolean; + error: { + message: string; + details: string[]; + }; +} + +export interface ISuccessResponse { + status: HttpStatus; + success: boolean; + results: Record; +} diff --git a/src/common/interfaces/PageFormatInterface.ts b/src/common/interfaces/PageFormatInterface.ts new file mode 100644 index 0000000..9509797 --- /dev/null +++ b/src/common/interfaces/PageFormatInterface.ts @@ -0,0 +1,8 @@ +export interface IPageFormat { + page: number; + limit: number; + totalItems: number; + totalPages: number; + prevPage: string | boolean; + nextPage: string | boolean; +} diff --git a/src/common/interfaces/PaginateDataFormatInterface.ts b/src/common/interfaces/PaginateDataFormatInterface.ts new file mode 100644 index 0000000..0acb85c --- /dev/null +++ b/src/common/interfaces/PaginateDataFormatInterface.ts @@ -0,0 +1,6 @@ +import { IPageFormat } from "./PageFormatInterface"; + +export interface IPaginateDataFormat { + pager: IPageFormat; + // docs: Array>; +} diff --git a/src/common/types/env.d.ts b/src/common/types/env.d.ts new file mode 100644 index 0000000..5e8fd68 --- /dev/null +++ b/src/common/types/env.d.ts @@ -0,0 +1,61 @@ +declare namespace NodeJS { + interface ProcessEnv { + PORT: string; + NODE_ENV: string; + + MONGO_URI: string; + REDIS_URI: string; + REDIS_HOST: string; + REDIS_PORT: string; + REDIS_PASSWORD: string; + + SMS_API_KEY: string; + SMS_SECRET: string; + SMS_NUMBER: string; + SMS_PATTERN_OTP: string; + SMS_PATTERN_USER_ORDER_PEND: string; + SMS_PATTERN_USER_ORDER_CREATE: string; + SMS_PATTERN_USER_ORDER_SHIPPED: string; + SMS_PATTERN_USER_ORDER_RETURNED: string; + SMS_PATTERN_SELLER_ORDER_CREATE: string; + SMS_PATTERN_SELLER_ORDER_CANCELLED: string; + + SMTP_EMAIL: string; + SMTP_HOST: string; + SMTP_PORT: string; + SMTPS_PORT: string; + SMTP_SECURE: string; + SMTP_USER: string; + SMTP_PASSWORD: string; + + JWT_SECRET: string; + JWT_EXPIRE_ACCESS: string; + JWT_EXPIRE_REFRESH: string; + + BUCKET_ACCESS_KEY: string; + BUCKET_SECRET_KEY: string; + + BUCKET_NAME: string; + BUCKET_URL: string; + + DEFAULT_ADMIN_EMAIL: string; + DEFAULT_ADMIN_PASSWORD: string; + + IPG_TYPE: string; + SITE_URL: string; + STORE_URL: string; + SELLER_URL: string; + + ZARINPAL_MERCHANT_ID: string; + + ASANPARDAKHT_MERCHANT_ID: string; + ASANPARDAKHT_MERCHANT_CONFIG_ID: string; + ASANPARDAKHT_USER: string; + ASANPARDAKHT_PASS: string; + + MAP_API_KEY: string; + NESHAN_API_KEY: string; + + LOGGER_LEVEL: string; + } +} diff --git a/src/common/types/jwt.type.ts b/src/common/types/jwt.type.ts new file mode 100644 index 0000000..2c9cabb --- /dev/null +++ b/src/common/types/jwt.type.ts @@ -0,0 +1,9 @@ +export type AuthTokenPayload = { + sub: string; +}; + +export type TokenType = "Access" | "Refresh"; + +export type AuthAdminToken = { + sub: string; +}; diff --git a/src/common/types/paymentGateway.type.ts b/src/common/types/paymentGateway.type.ts new file mode 100644 index 0000000..e7bb38f --- /dev/null +++ b/src/common/types/paymentGateway.type.ts @@ -0,0 +1,42 @@ +interface MetaData { + mobile?: string; + email?: string; + order_id?: string; +} + +export interface ZarinPalPGNewArgs { + merchant_id: string; + amount: number; + description: string; + callback_url: string; + metadata?: MetaData; + currency?: "IRR" | "IRT"; +} + +interface PaymentRequestData { + code: number; + message: string; + authority: string; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGNewRequestData { + data: PaymentRequestData; + error: string[]; +} + +interface PaymentVerifyData { + code: number; + message: string; + card_hash: string; + card_pan: string; + ref_id: number; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGVerifyData { + data: PaymentVerifyData; + error: string[]; +} diff --git a/src/common/types/query.type.ts b/src/common/types/query.type.ts new file mode 100644 index 0000000..31da8cb --- /dev/null +++ b/src/common/types/query.type.ts @@ -0,0 +1,102 @@ +import { NotificationType } from "../../modules/notification/models/Abstraction/INotification"; + +export type CompareSearchQueries = { + limit: number; + page: number; + productId: number; +}; + +export type VariantQueries = { + page: number; + limit: number; + variantStatus: boolean; + shipmentMethod: number; + stock: boolean; + includeAd: boolean; + specialSale: boolean; + sort: string[]; + q: string; +}; + +export type SellerProductQueries = { + page: number; + limit: number; + categoryId: string; + status: string; + q: string; +}; + +export type FilterByStatusProductsQueries = { + page: number; + limit: number; + status: string; +}; + +export type ProductsCommentsQueries = { + page: number; + limit: number; + productId: number; + status: string; +}; + +export type ProductsQuestionsQueries = { + page: number; + limit: number; + productId: number; + status: string; +}; + +export type SellerOrdersQueries = { + page: number; + limit: number; + shipperId: string; + status: string; + since: number; + maxPrice: number; + minPrice: number; +}; + +export type PaymentsQueries = { + page: number; + limit: number; + status: string; + since: number; + maxPrice: number; + minPrice: number; +}; + +export type AdminContactUsQueries = { + page: number; + limit: number; + email_phone: string; +}; + +export type AdminReturnsQueries = { + page: number; + limit: number; +}; + +export type AdminCancelsQueries = { + page: number; + limit: number; +}; + +export type SellerFinesQueries = { + page: number; + limit: number; +}; + +export type AdminFinesQueries = { + page: number; + limit: number; +}; + +export type NotificationQuery = { + page: number; + limit: number; + unread: boolean; + type: NotificationType; + since: number; +}; + +export type OrderStatusQuery = "Delivered" | "Processing" | "Cancelled"; diff --git a/src/common/types/request.d.ts b/src/common/types/request.d.ts new file mode 100644 index 0000000..7fc68fa --- /dev/null +++ b/src/common/types/request.d.ts @@ -0,0 +1,16 @@ +// /* eslint-disable no-unused-vars */ +// /* eslint-disable @typescript-eslint/no-unused-vars */ +// import { ApiError } from "../../core/app/app.errors"; +// // import express from "express"; +// // import { Express } from "express"; +// // import { Request } from "express"; + +// declare global { +// namespace Express { +// interface authError extends ApiError {} +// interface Request { +// authError?: authError; +// file?: Express.MulterS3.File; +// } +// } +// } diff --git a/src/core/app/app.errorHandler.ts b/src/core/app/app.errorHandler.ts new file mode 100644 index 0000000..f46ec15 --- /dev/null +++ b/src/core/app/app.errorHandler.ts @@ -0,0 +1,40 @@ +import { ErrorRequestHandler, RequestHandler } from "express"; +import { MongooseError } from "mongoose"; + +import { ApiError, NotFoundError } from "./app.errors"; +import { HttpStatus, IErrorResponse, ResponseFactory } from "../../common"; +import { Logger } from "../logging/logger"; + +const logger = new Logger(); + +export function notFoundHandler(): RequestHandler { + // eslint-disable-next-line no-unused-vars + return (req, _res, _next) => { + throw new NotFoundError(`can not get ${req.path}`); + }; +} + +export function errorHandler(): ErrorRequestHandler { + // eslint-disable-next-line no-unused-vars + return (err: Error, req, res, _next) => { + const trace = process.env.NODE_ENV === "development" ? { ...err, stack: err.stack } : { ...err, stack: err.stack }; + res.on("finish", () => { + logger.error(`[${req.ip}] - ${req.method} ${req.path} - ${res.statusCode}`, trace); + }); + const errCode: HttpStatus = err instanceof ApiError ? err.code : 500; + const errMsg = + err instanceof ApiError ? err.message : err instanceof MongooseError ? err.name : "Something went wrong. Please try again later."; + const errDetails = err instanceof ApiError ? err.details : err instanceof MongooseError ? [err.message] : []; + const errResponse: IErrorResponse = ResponseFactory.errorResponse(errCode, errMsg, errDetails); + + res.status(errCode).json(errResponse); + }; +} +export function lastHandler(): ErrorRequestHandler { + // eslint-disable-next-line no-unused-vars + return (err: Error, _req, res, _next) => { + logger.error(`Last error catcher: ${err.message}`); + const errResponse: IErrorResponse = ResponseFactory.errorResponse(500, "Something went wrong. Please try again later"); + res.status(500).json(errResponse); + }; +} diff --git a/src/core/app/app.errors.ts b/src/core/app/app.errors.ts new file mode 100644 index 0000000..9c74c1b --- /dev/null +++ b/src/core/app/app.errors.ts @@ -0,0 +1,62 @@ +export class ApiError extends Error { + readonly code: number; + readonly message: string; + readonly details: string[]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(code: number, message: string, details: string[] = [], ...args: any[]) { + super(message, ...args); + this.code = code; + this.message = message; + this.details = details; + } +} + +export class BadRequestError extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(400, "Bad request", messageArray); + } +} + +export class UnauthorizedError extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(401, "Unauthorized", messageArray); + } +} + +export class ForbiddenError extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(403, "Forbidden", messageArray); + } +} + +export class NotFoundError extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(404, "NotFound", messageArray); + } +} + +export class InternalError extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(500, "something went wrong please try again later", messageArray); + } +} + +export class jwtExpiredErr extends ApiError { + constructor(details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(401, "token has been expired!", messageArray); + } +} + +export class UploadError extends ApiError { + constructor(fieldName: string, details: string | string[] = []) { + const messageArray = Array.isArray(details) ? details : [details]; + super(422, `error uploading file in field ${fieldName}`, messageArray); + } +} diff --git a/src/core/config/app.config.ts b/src/core/config/app.config.ts new file mode 100644 index 0000000..a63ab07 --- /dev/null +++ b/src/core/config/app.config.ts @@ -0,0 +1,12 @@ +import { IAppOptions } from "../../common"; + +export const appConfig: IAppOptions = { + cors: { origin: true, credentials: true, optionsSuccessStatus: 204 }, + rate: { + windowMs: 5 * 60 * 1000, // 5 min + message: "Too many requests from this IP, please try again after an hour", + limit: 11, + standardHeaders: true, //rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // disable the `X-RateLimit-*` headers + }, +}; diff --git a/src/core/logging/logger.ts b/src/core/logging/logger.ts new file mode 100644 index 0000000..07dcfc7 --- /dev/null +++ b/src/core/logging/logger.ts @@ -0,0 +1,76 @@ +import colors from "ansi-colors"; +import { injectable } from "inversify"; +import stripAnsi from "strip-ansi"; +import winston, { Logger as WinstonLogger } from "winston"; + +const { combine, timestamp, printf, errors } = winston.format; + +const logLevelColor: Record = { + error: "red", + warn: "yellow", + info: "green", + verbose: "cyan", + debug: "blue", +}; + +const customFormat1 = printf(({ level, message, timestamp, context = "App", ...meta }) => { + const cleanedMessage = stripAnsi(message as string); + return `[${timestamp}] [${context}] [${level.toUpperCase()}]: ${cleanedMessage} ${Object.keys(meta).length ? JSON.stringify(meta, null, 0) : ""}`; +}); + +const customFormat2 = printf(({ level, message, timestamp, context = "App", trace, ...meta }) => { + const coloredTimestamp = colors.white(`[${timestamp}]`); + const Express = colors.green(`[Express]`); + const logLevel = logLevelColor[level]; + const coloredLevel = (colors[logLevel] as (str: string) => string)(`[${level.toUpperCase()}]`); + const coloredMessage = colors.green(message as string); + const coloredContext = colors.yellow(`[${context}]`); + + const errorTrace = trace instanceof Error ? trace.stack : trace ? JSON.stringify(trace, null, 4) : ""; + const metaLog = Object.keys(meta).length ? JSON.stringify(meta, null, 4) : ""; + + return `${Express} - ${coloredTimestamp} ${coloredLevel} ${coloredContext} : ${coloredMessage} ${errorTrace} ${metaLog}`; +}); + +@injectable() +export class Logger { + public logger: WinstonLogger; + + constructor(private context: string = "App") { + this.logger = winston.createLogger({ + level: process.env.LOGGER_LEVEL || "info", + format: combine(errors({ stack: true }), timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), customFormat1), + transports: [ + // new winston.transports.File({ filename: "logs/combined.log", handleExceptions: true }), + // new winston.transports.File({ level: "error", filename: "logs/error.log", handleExceptions: true }), + new winston.transports.Console({ + level: "debug", + format: combine(errors({ stack: true }), timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), customFormat2), + // handleExceptions: true, + }), + // new winston.transports.Http({}), + ], + exitOnError: false, + }); + } + + info(message: any, ...meta: any[]) { + this.logger.info({ message, context: this.context, ...meta }); + } + + error(message: any, trace?: any) { + this.logger.error({ message, trace, context: this.context }); + } + + warn(message: any, ...meta: any[]) { + this.logger.warn({ message, context: this.context, ...meta }); + } + + debug(message: any, ...meta: any[]) { + this.logger.debug({ message, context: this.context, ...meta }); + } + + verbose(message: any, ...meta: any[]) { + this.logger.verbose({ message, context: this.context, ...meta }); + } +} diff --git a/src/core/logging/morgan.ts b/src/core/logging/morgan.ts new file mode 100644 index 0000000..c4d8923 --- /dev/null +++ b/src/core/logging/morgan.ts @@ -0,0 +1,62 @@ +import ansiColor from "ansi-colors"; +import { Request, Response } from "express"; +import morgan from "morgan"; + +import { Logger } from "./logger"; + +const logger = new Logger("HTTP"); + +morgan.token("coloredMethod", (req: Request) => { + const method = req.method; + let color; + + switch (method) { + case "GET": + color = ansiColor.cyan; + break; + case "POST": + color = ansiColor.blue; + break; + case "PUT": + color = ansiColor.yellow; + break; + case "DELETE": + color = ansiColor.red; + break; + default: + color = ansiColor.white; + break; + } + + return color(method); +}); + +morgan.token("coloredStatus", (_req: Request, res: Response) => { + const status = res.statusCode; + let color; + + // Set color based on status code + if (status >= 500) { + color = ansiColor.red; + } else if (status >= 400) { + color = ansiColor.yellow; + } else if (status >= 300) { + color = ansiColor.cyan; + } else if (status >= 200) { + color = ansiColor.magenta; + } else { + color = ansiColor.white; + } + + return color(status.toString()); +}); + +const customMorganFormat = ":coloredMethod [:remote-addr] :url :coloredStatus :response-time ms - :user-agent - :res[content-length]"; + +const stream = { + write: (message: string) => { + logger.info(message.trim()); + }, +}; + +export { stream, customMorganFormat }; diff --git a/src/core/middlewares/guard.middleware.ts b/src/core/middlewares/guard.middleware.ts new file mode 100644 index 0000000..bacdd94 --- /dev/null +++ b/src/core/middlewares/guard.middleware.ts @@ -0,0 +1,131 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; +import { JsonWebTokenError, TokenExpiredError } from "jsonwebtoken"; +import passport from "passport"; + +import { AuthMessage } from "../../common/enums/message.enum"; +import { IAdmin } from "../../modules/admin/models/Abstraction/IAdmin"; +import { IPermission, PermissionEnum } from "../../modules/admin/models/Abstraction/IPermission"; +import { IRole, RoleEnum } from "../../modules/admin/models/Abstraction/IRole"; +import { ISeller } from "../../modules/seller/models/Abstraction/ISeller"; +import { IUser } from "../../modules/user/models/Abstraction/IUser"; +import { ForbiddenError, UnauthorizedError, jwtExpiredErr } from "../app/app.errors"; + +class Guard { + private static instance: Guard; + private constructor() {} + + static get(): Guard { + if (!Guard.instance) { + Guard.instance = new Guard(); + } + return Guard.instance; + } + /** + * Check if the admin has the required role + * @param {RoleEnum[]} requiredRoles The role that the admin must have (e.g., [SUPERADMIN, MODERATOR]) + */ + public checkAdminRole = + (requiredRoles: RoleEnum[]): RequestHandler => + (req, _res, next) => { + const admin = req.user as IAdmin; + const adminRole = admin.role as unknown as IRole; + + if (!admin.role || !requiredRoles.includes(adminRole.name)) { + return next(new ForbiddenError(AuthMessage.InsufficientRole)); + } + next(); + }; + + /** + * Check if the admin has the required permissions + * @param requiredPermissions The list of permissions the admin must have (e.g., [BRAND, CATEGORY]) + */ + public checkAdminPermissions = + (requiredPermissions: PermissionEnum[]): RequestHandler => + (req, _res, next) => { + const admin = req.user as IAdmin; + const adminRole = admin.role as unknown as IRole; + const adminPermissions = admin.permissions as unknown as IPermission[]; + const isSuperAdmin = adminRole.name == RoleEnum.SUPERADMIN; + + if (!adminRole || (!isSuperAdmin && !adminPermissions)) { + return next(new ForbiddenError(AuthMessage.InsufficientPermissions)); + } + + const hasAllPermissions = requiredPermissions.every((permission) => + adminPermissions.some((adminPerm) => adminPerm.name === permission), + ); + + if (!hasAllPermissions && !isSuperAdmin) { + return next(new ForbiddenError(AuthMessage.InsufficientPermissions)); + } + + next(); + }; + + /** + * callback function to handle authentication based on the sent jwt token to the endpoint + * @param req + * @param _res + * @param next + */ + public handleJwt = + (req: Request, _res: Response, next: NextFunction) => + async (err: Error, decoded: IUser | ISeller, info: string | object | JsonWebTokenError | TokenExpiredError): Promise => { + if (err) { + console.log({ err }); + return next(new UnauthorizedError(AuthMessage.AuthFailed)); + } + if (info instanceof TokenExpiredError) { + return next(new jwtExpiredErr([`${info.expiredAt}`])); + } + if (info instanceof JsonWebTokenError) { + return next(new UnauthorizedError(info.message)); + } + if (info) { + console.log({ info }); + return next(new UnauthorizedError(AuthMessage.AuthFailed)); + } + if (!decoded) { + return next(new UnauthorizedError(AuthMessage.NeedLogin)); + } + + req.user = decoded; + next(); + }; + + public authUser = (): RequestHandler => async (req, res, next) => { + passport.authenticate("UserJwt", { session: false }, this.handleJwt(req, res, next))(req, res, next); + }; + public authSeller = (): RequestHandler => async (req, res, next) => { + passport.authenticate("SellerJwt", { session: false }, this.handleJwt(req, res, next))(req, res, next); + }; + public authAdmin = (): RequestHandler => async (req, res, next) => { + passport.authenticate("AdminJwt", { session: false }, this.handleJwt(req, res, next))(req, res, next); + }; + + public AuthSellerOrAdmin = (): RequestHandler => async (req, res, next) => { + passport.authenticate("SellerOrAdmin", { session: false }, this.handleJwt(req, res, next))(req, res, next); + }; + + public authOptional = (): RequestHandler => async (req, res, next) => { + passport.authenticate( + "OptionalUserJwt", + { session: false }, + async (err: Error, decoded: IUser, info: string | object | JsonWebTokenError | TokenExpiredError) => { + if (err || info instanceof JsonWebTokenError || info instanceof TokenExpiredError) { + // اگر مشکلی در توکن وجود دارد، درخواست ادامه می‌یابد بدون خطا + return next(); + } + if (decoded) { + req.user = decoded; // اگر توکن معتبر است، کاربر را به `req.user` اضافه کنید + } + next(); + }, + )(req, res, next); + }; +} + +const instance = Guard.get(); + +export { instance as Guard }; diff --git a/src/core/middlewares/validator.middleware.ts b/src/core/middlewares/validator.middleware.ts new file mode 100644 index 0000000..87a6575 --- /dev/null +++ b/src/core/middlewares/validator.middleware.ts @@ -0,0 +1,112 @@ +import { ClassConstructor, plainToInstance } from "class-transformer"; +import { ValidationError, validate } from "class-validator"; +import { NextFunction, Request, RequestHandler, Response } from "express"; +import { injectable } from "inversify"; + +import { BadRequestError } from "../app/app.errors"; + +@injectable() +export class ValidationMiddleware { + static validateInput(DTO: ClassConstructor): RequestHandler { + return async (req: Request, _res: Response, next: NextFunction): Promise => { + try { + // Convert the request body to an instance of the DTO class + const validationClass = plainToInstance(DTO, req.body, { + excludeExtraneousValues: true, + }); + + // Validate the DTO instance, including nested objects + const errors: ValidationError[] = await validate(validationClass, { + whitelist: true, + // forbidNonWhitelisted: true, + // transform: true, + }); + if (errors.length > 0) { + const errorMsg = errors.map((error) => ValidationMiddleware.flattenValidationErrors(error)).flat(); + + next(new BadRequestError(errorMsg)); + // throw new BadRequestError("Bad Request", errorMsg); + } else { + req.body = validationClass; + next(); + } + } catch (error) { + next(error); + } + }; + } + + static validateParameter(DTO: ClassConstructor): RequestHandler { + return async (req: Request, _res: Response, next: NextFunction): Promise => { + try { + // Convert the request body to an instance of the DTO class + const validationClass = plainToInstance(DTO, req.params, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + console.log(validationClass); + + // Validate the DTO instance, including nested objects + const errors: ValidationError[] = await validate(validationClass, { + whitelist: true, + // forbidNonWhitelisted: true, + // transform: true, + }); + if (errors.length > 0) { + const errorMsg = errors.map((error) => ValidationMiddleware.flattenValidationErrors(error)).flat(); + + next(new BadRequestError(errorMsg)); + // throw new BadRequestError("Bad Request", errorMsg); + } else { + req.query = validationClass; + next(); + } + } catch (error) { + next(error); + } + }; + } + + static validateQuery(DTO: ClassConstructor): RequestHandler { + return async (req: Request, _res: Response, next: NextFunction): Promise => { + try { + // Convert the request body to an instance of the DTO class + const validationClass = plainToInstance(DTO, req.query, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + // Validate the DTO instance, including nested objects + const errors: ValidationError[] = await validate(validationClass, { + whitelist: true, + // forbidNonWhitelisted: true, + // transform: true, + }); + if (errors.length > 0) { + const errorMsg = errors.map((error) => ValidationMiddleware.flattenValidationErrors(error)).flat(); + + next(new BadRequestError(errorMsg)); + // throw new BadRequestError("Bad Request", errorMsg); + } else { + req.query = validationClass; + next(); + } + } catch (error) { + next(error); + } + }; + } + + // Helper function to recursively flatten nested validation errors + private static flattenValidationErrors(error: ValidationError): string[] { + if (error.constraints) { + return Object.values(error.constraints); + } + + if (error.children && error.children.length > 0) { + return error.children.map(ValidationMiddleware.flattenValidationErrors).flat(); + } + + return []; + } +} diff --git a/src/db/clearDatabase.ts b/src/db/clearDatabase.ts new file mode 100644 index 0000000..66e003f --- /dev/null +++ b/src/db/clearDatabase.ts @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +import { Logger } from "../core/logging/logger"; + +export const clearDatabase = async (logger: Logger) => { + try { + const db = mongoose.connection.db; + if (db) { + // Get all collections + const collections = await db.listCollections().toArray(); + + collections + .map((collection) => collection.name) + .forEach(async (collectionName) => { + logger.debug(`Dropped collection: ${collectionName}`, "Database"); + await db.dropCollection(collectionName); + }); + } + } catch (error) { + logger.error(`error happened in drop collections`, error); + } + + const collections = mongoose.connection.collections; + for (const key in collections) { + const collection = collections[key]; + try { + await collection.drop(); + logger.debug(`Dropped collection: ${key}`, "Database"); + } catch (error: unknown) { + if (error instanceof Error) { + if (error.message === "ns not found") { + logger.warn(`Collection not found: ${key}`, "Database"); + continue; + } else if (error.message.includes("a background operation is currently running")) { + logger.warn(`Skipping collection due to background operation: ${key}`, "Database"); + continue; + } else { + throw error; + } + } + } + } +}; diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..2cda75e --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,18 @@ +import Redis from "ioredis"; +import mongoose from "mongoose"; + +const MONGO_URI = process.env.MONGO_URI as string; + +export const connectMongo = async () => { + await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 2000 }); +}; + +export const connectRedis = () => { + // return new Redis({ + // host: process.env.REDIS_HOST, + // port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379, + // password: process.env.REDIS_PASSWORD, + // maxRetriesPerRequest: null, + // }); + return new Redis(process.env.REDIS_URI, { maxRetriesPerRequest: null }); +}; diff --git a/src/db/data/json/ostan.json b/src/db/data/json/ostan.json new file mode 100644 index 0000000..9151c82 --- /dev/null +++ b/src/db/data/json/ostan.json @@ -0,0 +1,33 @@ +[ + { "id": 1, "name": "آذربایجان شرقی" }, + { "id": 2, "name": "آذربایجان غربی" }, + { "id": 3, "name": "اردبیل" }, + { "id": 4, "name": "اصفهان" }, + { "id": 5, "name": "البرز" }, + { "id": 6, "name": "ایلام" }, + { "id": 7, "name": "بوشهر" }, + { "id": 8, "name": "تهران" }, + { "id": 9, "name": "چهارمحال وبختیاری" }, + { "id": 10, "name": "خراسان جنوبی" }, + { "id": 11, "name": "خراسان رضوی" }, + { "id": 12, "name": "خراسان شمالی" }, + { "id": 13, "name": "خوزستان" }, + { "id": 14, "name": "زنجان" }, + { "id": 15, "name": "سمنان" }, + { "id": 16, "name": "سیستان وبلوچستان" }, + { "id": 17, "name": "فارس" }, + { "id": 18, "name": "قزوین" }, + { "id": 19, "name": "قم" }, + { "id": 20, "name": "کردستان" }, + { "id": 21, "name": "کرمان" }, + { "id": 22, "name": "کرمانشاه" }, + { "id": 23, "name": "کهگیلویه وبویراحمد" }, + { "id": 24, "name": "گلستان" }, + { "id": 25, "name": "گیلان" }, + { "id": 26, "name": "لرستان" }, + { "id": 27, "name": "مازندران" }, + { "id": 28, "name": "مرکزی" }, + { "id": 29, "name": "هرمزگان" }, + { "id": 30, "name": "همدان" }, + { "id": 31, "name": "یزد" } +] diff --git a/src/db/data/json/shahr.json b/src/db/data/json/shahr.json new file mode 100644 index 0000000..3820e44 --- /dev/null +++ b/src/db/data/json/shahr.json @@ -0,0 +1,1326 @@ +[ + { "id": 1, "name": "آب بر", "ostan": 14 }, + { "id": 2, "name": "آب پخش", "ostan": 7 }, + { "id": 3, "name": "آباد", "ostan": 7 }, + { "id": 4, "name": "آبادان", "ostan": 13 }, + { "id": 8, "name": "آباده", "ostan": 17 }, + { "id": 9, "name": "آباده طشک", "ostan": 17 }, + { "id": 10, "name": "آبدان", "ostan": 7 }, + { "id": 11, "name": "آبدانان", "ostan": 6 }, + { "id": 12, "name": "آبژدان", "ostan": 13 }, + { "id": 13, "name": "آبسرد", "ostan": 8 }, + { "id": 14, "name": "آبش احمد", "ostan": 1 }, + { "id": 15, "name": "آبعلی", "ostan": 8 }, + { "id": 16, "name": "آبگرم", "ostan": 18 }, + { "id": 17, "name": "آبی بیگلو", "ostan": 3 }, + { "id": 18, "name": "آبیز", "ostan": 10 }, + { "id": 19, "name": "آبیک", "ostan": 18 }, + { "id": 20, "name": "آجین", "ostan": 30 }, + { "id": 21, "name": "آذرشهر", "ostan": 1 }, + { "id": 22, "name": "آرادان", "ostan": 15 }, + { "id": 23, "name": "آران وبیدگل", "ostan": 4 }, + { "id": 24, "name": "آرمرده", "ostan": 20 }, + { "id": 25, "name": "آرین شهر", "ostan": 10 }, + { "id": 26, "name": "آزادشهر", "ostan": 24 }, + { "id": 27, "name": "آزادی", "ostan": 13 }, + { "id": 28, "name": "آسارا", "ostan": 5 }, + { "id": 29, "name": "آستارا", "ostan": 25 }, + { "id": 30, "name": "آستانه", "ostan": 28 }, + { "id": 31, "name": "آستانه اشرفیه", "ostan": 25 }, + { "id": 32, "name": "آسمان آباد", "ostan": 6 }, + { "id": 33, "name": "آشار", "ostan": 16 }, + { "id": 34, "name": "آشتیان", "ostan": 28 }, + { "id": 35, "name": "آشخانه", "ostan": 12 }, + { "id": 36, "name": "آغاجاری", "ostan": 13 }, + { "id": 37, "name": "آق قلا", "ostan": 24 }, + { "id": 38, "name": "آقکند", "ostan": 1 }, + { "id": 39, "name": "آلاشت", "ostan": 27 }, + { "id": 40, "name": "آلونی", "ostan": 9 }, + { "id": 41, "name": "آمل", "ostan": 27 }, + { "id": 42, "name": "آوا", "ostan": 12 }, + { "id": 43, "name": "آواجیق", "ostan": 2 }, + { "id": 44, "name": "آوج", "ostan": 18 }, + { "id": 45, "name": "آوه", "ostan": 28 }, + { "id": 46, "name": "آیسک", "ostan": 10 }, + { "id": 47, "name": "ابرکوه", "ostan": 31 }, + { "id": 48, "name": "ابریشم", "ostan": 4 }, + { "id": 49, "name": "ابوحمیظه", "ostan": 13 }, + { "id": 50, "name": "ابوزیدآباد", "ostan": 4 }, + { "id": 51, "name": "ابوموسی", "ostan": 29 }, + { "id": 52, "name": "ابهر", "ostan": 14 }, + { "id": 53, "name": "اچاچی", "ostan": 1 }, + { "id": 54, "name": "احمد آباد مستوفی", "ostan": 8 }, + { "id": 55, "name": "احمدآباد", "ostan": 31 }, + { "id": 56, "name": "احمدابادصولت", "ostan": 11 }, + { "id": 57, "name": "احمدسرگوراب", "ostan": 25 }, + { "id": 58, "name": "اختیارآباد", "ostan": 21 }, + { "id": 59, "name": "ادیمی", "ostan": 16 }, + { "id": 60, "name": "اراک", "ostan": 28 }, + { "id": 66, "name": "اربطان", "ostan": 1 }, + { "id": 67, "name": "ارجمند", "ostan": 8 }, + { "id": 68, "name": "ارد", "ostan": 17 }, + { "id": 69, "name": "ارداق", "ostan": 18 }, + { "id": 70, "name": "اردبیل", "ostan": 3 }, + { "id": 76, "name": "اردستان", "ostan": 4 }, + { "id": 77, "name": "اردکان", "ostan": 17 }, + { "id": 78, "name": "اردکان", "ostan": 31 }, + { "id": 79, "name": "اردل", "ostan": 9 }, + { "id": 80, "name": "اردیموسی", "ostan": 3 }, + { "id": 81, "name": "ارزوییه", "ostan": 21 }, + { "id": 82, "name": "ارسک", "ostan": 10 }, + { "id": 83, "name": "ارسنجان", "ostan": 17 }, + { "id": 84, "name": "ارطه", "ostan": 27 }, + { "id": 85, "name": "ارکواز", "ostan": 6 }, + { "id": 86, "name": "ارمغانخانه", "ostan": 14 }, + { "id": 87, "name": "ارومیه", "ostan": 2 }, + { "id": 93, "name": "اروندکنار", "ostan": 13 }, + { "id": 94, "name": "ازگله", "ostan": 22 }, + { "id": 95, "name": "ازنا", "ostan": 26 }, + { "id": 96, "name": "ازندریان", "ostan": 30 }, + { "id": 97, "name": "اژیه", "ostan": 4 }, + { "id": 98, "name": "اسالم", "ostan": 25 }, + { "id": 99, "name": "اسپکه", "ostan": 16 }, + { "id": 100, "name": "استهبان", "ostan": 17 }, + { "id": 101, "name": "اسدآباد", "ostan": 30 }, + { "id": 102, "name": "اسدیه", "ostan": 10 }, + { "id": 103, "name": "اسفدن", "ostan": 10 }, + { "id": 104, "name": "اسفراین", "ostan": 12 }, + { "id": 105, "name": "اسفرورین", "ostan": 18 }, + { "id": 106, "name": "اسکو", "ostan": 1 }, + { "id": 107, "name": "اسلام آبادغرب", "ostan": 22 }, + { "id": 108, "name": "اسلام اباد", "ostan": 3 }, + { "id": 109, "name": "اسلام شهر آق گل", "ostan": 30 }, + { "id": 113, "name": "اسلامشهر", "ostan": 8 }, + { "id": 114, "name": "اسلامیه", "ostan": 10 }, + { "id": 115, "name": "اسیر", "ostan": 17 }, + { "id": 116, "name": "اشترینان", "ostan": 26 }, + { "id": 117, "name": "اشتهارد", "ostan": 5 }, + { "id": 118, "name": "اشکذر", "ostan": 31 }, + { "id": 119, "name": "اشکنان", "ostan": 17 }, + { "id": 120, "name": "اشنویه", "ostan": 2 }, + { "id": 121, "name": "اصغرآباد", "ostan": 4 }, + { "id": 122, "name": "اصفهان", "ostan": 4 }, + { "id": 138, "name": "اصلاندوز", "ostan": 3 }, + { "id": 139, "name": "اطاقور", "ostan": 25 }, + { "id": 140, "name": "افزر", "ostan": 17 }, + { "id": 141, "name": "افوس", "ostan": 4 }, + { "id": 142, "name": "اقبالیه", "ostan": 18 }, + { "id": 143, "name": "اقلید", "ostan": 17 }, + { "id": 144, "name": "الشتر", "ostan": 26 }, + { "id": 145, "name": "الوان", "ostan": 13 }, + { "id": 146, "name": "الوند", "ostan": 18 }, + { "id": 147, "name": "الهایی", "ostan": 13 }, + { "id": 148, "name": "الیگودرز", "ostan": 26 }, + { "id": 149, "name": "امام حسن", "ostan": 7 }, + { "id": 150, "name": "امام شهر", "ostan": 17 }, + { "id": 151, "name": "امامزاده عبدالله", "ostan": 27 }, + { "id": 152, "name": "املش", "ostan": 25 }, + { "id": 153, "name": "امیدیه", "ostan": 13 }, + { "id": 154, "name": "امیرکلا", "ostan": 27 }, + { "id": 155, "name": "امیریه", "ostan": 15 }, + { "id": 156, "name": "امین شهر", "ostan": 21 }, + { "id": 157, "name": "انابد", "ostan": 11 }, + { "id": 158, "name": "انار", "ostan": 21 }, + { "id": 159, "name": "انارستان", "ostan": 7 }, + { "id": 160, "name": "انارک", "ostan": 4 }, + { "id": 161, "name": "انبارآلوم", "ostan": 24 }, + { "id": 162, "name": "اندوهجرد", "ostan": 21 }, + { "id": 163, "name": "اندیشه", "ostan": 8 }, + { "id": 164, "name": "اندیمشک", "ostan": 13 }, + { "id": 165, "name": "اورامان تخت", "ostan": 20 }, + { "id": 166, "name": "اوز", "ostan": 17 }, + { "id": 167, "name": "اهر", "ostan": 1 }, + { "id": 168, "name": "اهرم", "ostan": 7 }, + { "id": 169, "name": "اهل", "ostan": 17 }, + { "id": 170, "name": "اهواز", "ostan": 13 }, + { "id": 179, "name": "ایج", "ostan": 17 }, + { "id": 180, "name": "ایذه", "ostan": 13 }, + { "id": 181, "name": "ایرانشهر", "ostan": 16 }, + { "id": 182, "name": "ایزدخواست", "ostan": 17 }, + { "id": 183, "name": "ایزدشهر", "ostan": 27 }, + { "id": 184, "name": "ایلام", "ostan": 6 }, + { "id": 185, "name": "ایلخچی", "ostan": 1 }, + { "id": 186, "name": "ایمانشهر", "ostan": 4 }, + { "id": 187, "name": "اینچه برون", "ostan": 24 }, + { "id": 188, "name": "ایوان", "ostan": 6 }, + { "id": 189, "name": "ایوانکی", "ostan": 15 }, + { "id": 190, "name": "ایواوغلی", "ostan": 2 }, + { "id": 191, "name": "ایور", "ostan": 12 }, + { "id": 192, "name": "باب انار", "ostan": 17 }, + { "id": 193, "name": "باباحیدر", "ostan": 9 }, + { "id": 194, "name": "بابارشانی", "ostan": 20 }, + { "id": 195, "name": "بابامنیر", "ostan": 17 }, + { "id": 196, "name": "بابکان", "ostan": 27 }, + { "id": 197, "name": "بابل", "ostan": 27 }, + { "id": 200, "name": "بابلسر", "ostan": 27 }, + { "id": 201, "name": "باجگیران", "ostan": 11 }, + { "id": 202, "name": "باخرز", "ostan": 11 }, + { "id": 203, "name": "بادرود", "ostan": 4 }, + { "id": 204, "name": "بادوله", "ostan": 7 }, + { "id": 205, "name": "بار", "ostan": 11 }, + { "id": 206, "name": "باروق", "ostan": 2 }, + { "id": 207, "name": "بازار جمعه", "ostan": 25 }, + { "id": 208, "name": "بازرگان", "ostan": 2 }, + { "id": 209, "name": "بازفت", "ostan": 9 }, + { "id": 210, "name": "باسمنج", "ostan": 1 }, + { "id": 211, "name": "باشت", "ostan": 23 }, + { "id": 212, "name": "باغ بهادران", "ostan": 4 }, + { "id": 213, "name": "باغ ملک", "ostan": 13 }, + { "id": 214, "name": "باغستان", "ostan": 8 }, + { "id": 215, "name": "باغشاد", "ostan": 4 }, + { "id": 216, "name": "باغین", "ostan": 21 }, + { "id": 217, "name": "بافت", "ostan": 21 }, + { "id": 218, "name": "بافران", "ostan": 4 }, + { "id": 219, "name": "بافق", "ostan": 31 }, + { "id": 220, "name": "باقرشهر", "ostan": 8 }, + { "id": 221, "name": "بالاده", "ostan": 17 }, + { "id": 222, "name": "بالاشهر", "ostan": 29 }, + { "id": 223, "name": "بانوره", "ostan": 22 }, + { "id": 224, "name": "بانه", "ostan": 20 }, + { "id": 225, "name": "بایک", "ostan": 11 }, + { "id": 226, "name": "باینگان", "ostan": 22 }, + { "id": 227, "name": "بجستان", "ostan": 11 }, + { "id": 228, "name": "بجنورد", "ostan": 12 }, + { "id": 231, "name": "بخشایش", "ostan": 1 }, + { "id": 232, "name": "بدره", "ostan": 6 }, + { "id": 233, "name": "برازجان", "ostan": 7 }, + { "id": 234, "name": "بردخون", "ostan": 7 }, + { "id": 235, "name": "بردستان", "ostan": 7 }, + { "id": 236, "name": "بردسکن", "ostan": 11 }, + { "id": 237, "name": "بردسیر", "ostan": 21 }, + { "id": 238, "name": "برده رشه", "ostan": 20 }, + { "id": 239, "name": "برزک", "ostan": 4 }, + { "id": 240, "name": "برزول", "ostan": 30 }, + { "id": 241, "name": "برف انبار", "ostan": 4 }, + { "id": 242, "name": "بروات", "ostan": 21 }, + { "id": 243, "name": "بروجرد", "ostan": 26 }, + { "id": 246, "name": "بروجن", "ostan": 9 }, + { "id": 247, "name": "بره سر", "ostan": 25 }, + { "id": 248, "name": "بزمان", "ostan": 16 }, + { "id": 249, "name": "بزنجان", "ostan": 21 }, + { "id": 250, "name": "بستان", "ostan": 13 }, + { "id": 251, "name": "بستان آباد", "ostan": 1 }, + { "id": 252, "name": "بستک", "ostan": 29 }, + { "id": 253, "name": "بسطام", "ostan": 15 }, + { "id": 254, "name": "بشرویه", "ostan": 10 }, + { "id": 255, "name": "بفروییه", "ostan": 31 }, + { "id": 256, "name": "بلاوه", "ostan": 6 }, + { "id": 257, "name": "بلبان آباد", "ostan": 20 }, + { "id": 258, "name": "بلداجی", "ostan": 9 }, + { "id": 259, "name": "بلده", "ostan": 27 }, + { "id": 260, "name": "بلورد", "ostan": 21 }, + { "id": 261, "name": "بلوک", "ostan": 21 }, + { "id": 262, "name": "بم", "ostan": 21 }, + { "id": 263, "name": "بمپور", "ostan": 16 }, + { "id": 264, "name": "بن", "ostan": 9 }, + { "id": 265, "name": "بناب", "ostan": 1 }, + { "id": 266, "name": "بناب مرند", "ostan": 1 }, + { "id": 267, "name": "بنارویه", "ostan": 17 }, + { "id": 268, "name": "بنت", "ostan": 16 }, + { "id": 269, "name": "بنجار", "ostan": 16 }, + { "id": 270, "name": "بندرامام خمینی", "ostan": 13 }, + { "id": 271, "name": "بندرانزلی", "ostan": 25 }, + { "id": 274, "name": "بندرترکمن", "ostan": 24 }, + { "id": 275, "name": "بندرجاسک", "ostan": 29 }, + { "id": 276, "name": "بندردیر", "ostan": 7 }, + { "id": 277, "name": "بندردیلم", "ostan": 7 }, + { "id": 278, "name": "بندرریگ", "ostan": 7 }, + { "id": 279, "name": "بندرعباس", "ostan": 29 }, + { "id": 283, "name": "بندرکنگان", "ostan": 7 }, + { "id": 284, "name": "بندرگز", "ostan": 24 }, + { "id": 285, "name": "بندرگناوه", "ostan": 7 }, + { "id": 286, "name": "بندرلنگه", "ostan": 29 }, + { "id": 287, "name": "بندرماهشهر", "ostan": 13 }, + { "id": 288, "name": "بندزرک", "ostan": 29 }, + { "id": 289, "name": "بنک", "ostan": 7 }, + { "id": 290, "name": "بوانات", "ostan": 17 }, + { "id": 291, "name": "بویین زهرا", "ostan": 18 }, + { "id": 292, "name": "بویین سفلی", "ostan": 20 }, + { "id": 293, "name": "بوستان", "ostan": 23 }, + { "id": 294, "name": "بوشکان", "ostan": 7 }, + { "id": 295, "name": "بوشهر", "ostan": 7 }, + { "id": 298, "name": "بوکان", "ostan": 2 }, + { "id": 299, "name": "بومهن", "ostan": 8 }, + { "id": 300, "name": "بویین ومیاندشت", "ostan": 4 }, + { "id": 301, "name": "بهاباد", "ostan": 31 }, + { "id": 302, "name": "بهار", "ostan": 30 }, + { "id": 303, "name": "بهاران شهر", "ostan": 4 }, + { "id": 304, "name": "بهارستان", "ostan": 4 }, + { "id": 305, "name": "بهارستان", "ostan": 7 }, + { "id": 306, "name": "بهبهان", "ostan": 13 }, + { "id": 307, "name": "بهرمان", "ostan": 21 }, + { "id": 308, "name": "بهشهر", "ostan": 27 }, + { "id": 309, "name": "بهمن", "ostan": 17 }, + { "id": 310, "name": "بهنمیر", "ostan": 27 }, + { "id": 311, "name": "بیارجمند", "ostan": 15 }, + { "id": 312, "name": "بیجار", "ostan": 20 }, + { "id": 313, "name": "بیدخت", "ostan": 11 }, + { "id": 314, "name": "بیدخون", "ostan": 7 }, + { "id": 315, "name": "بیدروبه", "ostan": 13 }, + { "id": 316, "name": "بیدستان", "ostan": 18 }, + { "id": 317, "name": "بیده", "ostan": 4 }, + { "id": 318, "name": "بیران شهر", "ostan": 26 }, + { "id": 319, "name": "بیرجند", "ostan": 10 }, + { "id": 322, "name": "بیرم", "ostan": 17 }, + { "id": 323, "name": "بیستون", "ostan": 22 }, + { "id": 324, "name": "بیضا", "ostan": 17 }, + { "id": 325, "name": "بیکاء", "ostan": 29 }, + { "id": 326, "name": "بیله سوار", "ostan": 3 }, + { "id": 327, "name": "پاتاوه", "ostan": 23 }, + { "id": 328, "name": "پارس آباد", "ostan": 3 }, + { "id": 329, "name": "پارسیان", "ostan": 29 }, + { "id": 330, "name": "پارود", "ostan": 16 }, + { "id": 331, "name": "پاریز", "ostan": 21 }, + { "id": 332, "name": "پاکدشت", "ostan": 8 }, + { "id": 333, "name": "پاوه", "ostan": 22 }, + { "id": 334, "name": "پایین هولار", "ostan": 27 }, + { "id": 335, "name": "پردنجان", "ostan": 9 }, + { "id": 336, "name": "پردیس", "ostan": 8 }, + { "id": 337, "name": "پرند", "ostan": 8 }, + { "id": 338, "name": "پرندک", "ostan": 28 }, + { "id": 339, "name": "پره سر", "ostan": 25 }, + { "id": 340, "name": "پل", "ostan": 29 }, + { "id": 341, "name": "پل سفید", "ostan": 27 }, + { "id": 342, "name": "پلان", "ostan": 16 }, + { "id": 343, "name": "پلدختر", "ostan": 26 }, + { "id": 344, "name": "پلدشت", "ostan": 2 }, + { "id": 345, "name": "پول", "ostan": 27 }, + { "id": 346, "name": "پهله", "ostan": 6 }, + { "id": 347, "name": "پیرانشهر", "ostan": 2 }, + { "id": 348, "name": "پیربکران", "ostan": 4 }, + { "id": 349, "name": "پیرتاج", "ostan": 20 }, + { "id": 350, "name": "پیش قلعه", "ostan": 12 }, + { "id": 351, "name": "پیشوا", "ostan": 8 }, + { "id": 352, "name": "پیشین", "ostan": 16 }, + { "id": 353, "name": "تاتارعلیا", "ostan": 24 }, + { "id": 354, "name": "تازه آباد", "ostan": 22 }, + { "id": 355, "name": "تازه شهر", "ostan": 2 }, + { "id": 356, "name": "تازه کندانگوت", "ostan": 3 }, + { "id": 357, "name": "تازه کندنصرت آباد", "ostan": 2 }, + { "id": 358, "name": "تازیان پایین", "ostan": 29 }, + { "id": 359, "name": "تاکستان", "ostan": 18 }, + { "id": 360, "name": "تایباد", "ostan": 11 }, + { "id": 361, "name": "تبریز", "ostan": 1 }, + { "id": 364, "name": "تبریز1-", "ostan": 1 }, + { "id": 365, "name": "تبریز2-", "ostan": 1 }, + { "id": 366, "name": "تبریز3-", "ostan": 1 }, + { "id": 367, "name": "تبریز4-", "ostan": 1 }, + { "id": 372, "name": "تجریش", "ostan": 8 }, + { "id": 373, "name": "تخت", "ostan": 29 }, + { "id": 374, "name": "تربت جام", "ostan": 11 }, + { "id": 375, "name": "تربت حیدریه", "ostan": 11 }, + { "id": 376, "name": "ترک", "ostan": 1 }, + { "id": 377, "name": "ترکالکی", "ostan": 13 }, + { "id": 378, "name": "ترکمانچای", "ostan": 1 }, + { "id": 379, "name": "تسوج", "ostan": 1 }, + { "id": 380, "name": "تشان", "ostan": 13 }, + { "id": 381, "name": "تفت", "ostan": 31 }, + { "id": 382, "name": "تفرش", "ostan": 28 }, + { "id": 383, "name": "تکاب", "ostan": 2 }, + { "id": 384, "name": "تلخاب", "ostan": 28 }, + { "id": 385, "name": "تنکابن", "ostan": 27 }, + { "id": 386, "name": "تنکمان", "ostan": 5 }, + { "id": 387, "name": "تنگ ارم", "ostan": 7 }, + { "id": 388, "name": "توپ آغاج", "ostan": 20 }, + { "id": 389, "name": "توتکابن", "ostan": 25 }, + { "id": 390, "name": "توحید", "ostan": 6 }, + { "id": 391, "name": "تودشک", "ostan": 4 }, + { "id": 392, "name": "توره", "ostan": 28 }, + { "id": 393, "name": "تویسرکان", "ostan": 30 }, + { "id": 394, "name": "تهران", "ostan": 8 }, + { "id": 417, "name": "تیتکانلو", "ostan": 12 }, + { "id": 418, "name": "تیران", "ostan": 4 }, + { "id": 419, "name": "تیرور", "ostan": 29 }, + { "id": 420, "name": "تیکمه داش", "ostan": 1 }, + { "id": 421, "name": "تیمورلو", "ostan": 1 }, + { "id": 422, "name": "ثمرین", "ostan": 3 }, + { "id": 423, "name": "جاجرم", "ostan": 12 }, + { "id": 424, "name": "جالق", "ostan": 16 }, + { "id": 425, "name": "جاورسیان", "ostan": 28 }, + { "id": 426, "name": "جایزان", "ostan": 13 }, + { "id": 427, "name": "جبالبارز", "ostan": 21 }, + { "id": 428, "name": "جعفرآباد", "ostan": 3 }, + { "id": 429, "name": "جعفریه", "ostan": 19 }, + { "id": 430, "name": "جغتای", "ostan": 11 }, + { "id": 431, "name": "جلفا", "ostan": 1 }, + { "id": 432, "name": "جلین", "ostan": 24 }, + { "id": 433, "name": "جم", "ostan": 7 }, + { "id": 434, "name": "جناح", "ostan": 29 }, + { "id": 435, "name": "جنت شهر", "ostan": 17 }, + { "id": 436, "name": "جنت مکان", "ostan": 13 }, + { "id": 437, "name": "جندق", "ostan": 4 }, + { "id": 438, "name": "جنگل", "ostan": 11 }, + { "id": 439, "name": "جوادآباد", "ostan": 8 }, + { "id": 440, "name": "جوان قلعه", "ostan": 1 }, + { "id": 441, "name": "جوانرود", "ostan": 22 }, + { "id": 442, "name": "جوپار", "ostan": 21 }, + { "id": 443, "name": "جورقان", "ostan": 30 }, + { "id": 444, "name": "جوزدان", "ostan": 4 }, + { "id": 445, "name": "جوزم", "ostan": 21 }, + { "id": 446, "name": "جوشقان قالی", "ostan": 4 }, + { "id": 447, "name": "جوکار", "ostan": 30 }, + { "id": 448, "name": "جولکی", "ostan": 13 }, + { "id": 449, "name": "جونقان", "ostan": 9 }, + { "id": 450, "name": "جویبار", "ostan": 27 }, + { "id": 451, "name": "جویم", "ostan": 17 }, + { "id": 452, "name": "جهرم", "ostan": 17 }, + { "id": 453, "name": "جیرفت", "ostan": 21 }, + { "id": 454, "name": "جیرنده", "ostan": 25 }, + { "id": 455, "name": "چابکسر", "ostan": 25 }, + { "id": 456, "name": "چابهار", "ostan": 16 }, + { "id": 457, "name": "چاپشلو", "ostan": 11 }, + { "id": 458, "name": "چادگان", "ostan": 4 }, + { "id": 459, "name": "چارک", "ostan": 29 }, + { "id": 460, "name": "چاف و چمخاله", "ostan": 25 }, + { "id": 461, "name": "چالانچولان", "ostan": 26 }, + { "id": 462, "name": "چالوس", "ostan": 27 }, + { "id": 463, "name": "چاه دادخدا", "ostan": 21 }, + { "id": 464, "name": "چاه مبارک", "ostan": 7 }, + { "id": 465, "name": "چاه ورز", "ostan": 17 }, + { "id": 466, "name": "چترود", "ostan": 21 }, + { "id": 467, "name": "چرام", "ostan": 23 }, + { "id": 468, "name": "چرمهین", "ostan": 4 }, + { "id": 469, "name": "چشمه شیرین", "ostan": 6 }, + { "id": 470, "name": "چغادک", "ostan": 7 }, + { "id": 471, "name": "چغامیش", "ostan": 13 }, + { "id": 472, "name": "چقابل", "ostan": 26 }, + { "id": 473, "name": "چکنه", "ostan": 11 }, + { "id": 474, "name": "چگرد", "ostan": 16 }, + { "id": 475, "name": "چلگرد", "ostan": 9 }, + { "id": 476, "name": "چلیچه", "ostan": 9 }, + { "id": 477, "name": "چم گلک", "ostan": 13 }, + { "id": 478, "name": "چمران", "ostan": 13 }, + { "id": 479, "name": "چمستان", "ostan": 27 }, + { "id": 480, "name": "چمگردان", "ostan": 4 }, + { "id": 481, "name": "چمن سلطان", "ostan": 26 }, + { "id": 482, "name": "چناران", "ostan": 11 }, + { "id": 483, "name": "چناران شهر", "ostan": 12 }, + { "id": 484, "name": "چناره", "ostan": 20 }, + { "id": 485, "name": "چوار", "ostan": 6 }, + { "id": 486, "name": "چوبر", "ostan": 25 }, + { "id": 487, "name": "چورزق", "ostan": 14 }, + { "id": 488, "name": "چویبده", "ostan": 13 }, + { "id": 489, "name": "چهارباغ", "ostan": 5 }, + { "id": 490, "name": "چهاربرج", "ostan": 2 }, + { "id": 491, "name": "چهاردانگه", "ostan": 8 }, + { "id": 492, "name": "چیتاب", "ostan": 23 }, + { "id": 493, "name": "حاجی آباد", "ostan": 17 }, + { "id": 494, "name": "حاجی آباد", "ostan": 10 }, + { "id": 495, "name": "حاجی اباد", "ostan": 29 }, + { "id": 496, "name": "حاجیلار", "ostan": 2 }, + { "id": 497, "name": "حبیب آباد", "ostan": 4 }, + { "id": 498, "name": "حر", "ostan": 13 }, + { "id": 499, "name": "حسامی", "ostan": 17 }, + { "id": 500, "name": "حسن آباد", "ostan": 8 }, + { "id": 501, "name": "حسن اباد", "ostan": 17 }, + { "id": 502, "name": "حسن اباد", "ostan": 4 }, + { "id": 503, "name": "حسین آباد", "ostan": 20 }, + { "id": 504, "name": "حسینیه", "ostan": 13 }, + { "id": 505, "name": "حصارگرمخان", "ostan": 12 }, + { "id": 506, "name": "حکم اباد", "ostan": 11 }, + { "id": 507, "name": "حلب", "ostan": 14 }, + { "id": 508, "name": "حمزه", "ostan": 13 }, + { "id": 509, "name": "حمیدیا", "ostan": 31 }, + { "id": 510, "name": "حمیدیه", "ostan": 13 }, + { "id": 511, "name": "حمیل", "ostan": 22 }, + { "id": 512, "name": "حنا", "ostan": 4 }, + { "id": 513, "name": "حویق", "ostan": 25 }, + { "id": 514, "name": "خاتون اباد", "ostan": 21 }, + { "id": 515, "name": "خارک", "ostan": 7 }, + { "id": 516, "name": "خاروانا", "ostan": 1 }, + { "id": 517, "name": "خاش", "ostan": 16 }, + { "id": 518, "name": "خاکعلی", "ostan": 18 }, + { "id": 519, "name": "خالدآباد", "ostan": 4 }, + { "id": 520, "name": "خامنه", "ostan": 1 }, + { "id": 521, "name": "خان ببین", "ostan": 24 }, + { "id": 522, "name": "خانوک", "ostan": 21 }, + { "id": 523, "name": "خانه زنیان", "ostan": 17 }, + { "id": 524, "name": "خانیمن", "ostan": 17 }, + { "id": 525, "name": "خاوران", "ostan": 17 }, + { "id": 526, "name": "خداجو(خراجو)", "ostan": 1 }, + { "id": 527, "name": "خرامه", "ostan": 17 }, + { "id": 528, "name": "خرم آباد", "ostan": 27 }, + { "id": 529, "name": "خرم آباد", "ostan": 26 }, + { "id": 533, "name": "خرمدره", "ostan": 14 }, + { "id": 534, "name": "خرمدشت", "ostan": 18 }, + { "id": 535, "name": "خرمشهر", "ostan": 13 }, + { "id": 536, "name": "خرو", "ostan": 11 }, + { "id": 537, "name": "خسروشاه", "ostan": 1 }, + { "id": 538, "name": "خشت", "ostan": 17 }, + { "id": 539, "name": "خشکبیجار", "ostan": 25 }, + { "id": 540, "name": "خشکرود", "ostan": 28 }, + { "id": 541, "name": "خضرآباد", "ostan": 31 }, + { "id": 542, "name": "خضری دشت بیاض", "ostan": 10 }, + { "id": 543, "name": "خلخال", "ostan": 3 }, + { "id": 544, "name": "خلیفان", "ostan": 2 }, + { "id": 545, "name": "خلیل آباد", "ostan": 11 }, + { "id": 546, "name": "خلیل شهر", "ostan": 27 }, + { "id": 547, "name": "خمارلو", "ostan": 1 }, + { "id": 548, "name": "خمام", "ostan": 25 }, + { "id": 549, "name": "خمیر", "ostan": 29 }, + { "id": 550, "name": "خمین", "ostan": 28 }, + { "id": 551, "name": "خمینی شهر", "ostan": 4 }, + { "id": 552, "name": "خنافره", "ostan": 13 }, + { "id": 553, "name": "خنج", "ostan": 17 }, + { "id": 554, "name": "خنجین", "ostan": 28 }, + { "id": 555, "name": "خنداب", "ostan": 28 }, + { "id": 556, "name": "خواجو شهر", "ostan": 21 }, + { "id": 557, "name": "خواجه", "ostan": 1 }, + { "id": 558, "name": "خواف", "ostan": 11 }, + { "id": 559, "name": "خوانسار", "ostan": 4 }, + { "id": 560, "name": "خوی", "ostan": 2 }, + { "id": 561, "name": "خور", "ostan": 17 }, + { "id": 562, "name": "خور", "ostan": 4 }, + { "id": 563, "name": "خورزوق", "ostan": 4 }, + { "id": 564, "name": "خورسند", "ostan": 21 }, + { "id": 565, "name": "خورموج", "ostan": 7 }, + { "id": 566, "name": "خوزی", "ostan": 17 }, + { "id": 567, "name": "خوسف", "ostan": 10 }, + { "id": 568, "name": "خوش رودپی", "ostan": 27 }, + { "id": 569, "name": "خوشه مهر", "ostan": 1 }, + { "id": 570, "name": "خومه زار", "ostan": 17 }, + { "id": 571, "name": "دابودشت", "ostan": 27 }, + { "id": 572, "name": "داراب", "ostan": 17 }, + { "id": 573, "name": "داران", "ostan": 4 }, + { "id": 574, "name": "دارخوین", "ostan": 13 }, + { "id": 575, "name": "داریان", "ostan": 17 }, + { "id": 576, "name": "دالکی", "ostan": 7 }, + { "id": 577, "name": "دامغان", "ostan": 15 }, + { "id": 578, "name": "دامنه", "ostan": 4 }, + { "id": 579, "name": "دانسفهان", "ostan": 18 }, + { "id": 580, "name": "داودآباد", "ostan": 28 }, + { "id": 581, "name": "داورزن", "ostan": 11 }, + { "id": 582, "name": "دبیران", "ostan": 17 }, + { "id": 583, "name": "درب بهشت", "ostan": 21 }, + { "id": 584, "name": "درب گنبد", "ostan": 26 }, + { "id": 585, "name": "درجزین", "ostan": 15 }, + { "id": 586, "name": "درچه", "ostan": 4 }, + { "id": 587, "name": "درح", "ostan": 10 }, + { "id": 588, "name": "درق", "ostan": 12 }, + { "id": 589, "name": "درگز", "ostan": 11 }, + { "id": 590, "name": "درگهان", "ostan": 29 }, + { "id": 591, "name": "درود", "ostan": 11 }, + { "id": 592, "name": "دره شهر", "ostan": 6 }, + { "id": 593, "name": "دزج", "ostan": 20 }, + { "id": 594, "name": "دزفول", "ostan": 13 }, + { "id": 598, "name": "دژکرد", "ostan": 17 }, + { "id": 599, "name": "دستجرد", "ostan": 19 }, + { "id": 600, "name": "دستگرد", "ostan": 4 }, + { "id": 601, "name": "دستنا", "ostan": 9 }, + { "id": 602, "name": "دشتک", "ostan": 9 }, + { "id": 603, "name": "دشتکار", "ostan": 21 }, + { "id": 604, "name": "دشتی", "ostan": 29 }, + { "id": 605, "name": "دلبران", "ostan": 20 }, + { "id": 606, "name": "دلگشا", "ostan": 6 }, + { "id": 607, "name": "دلند", "ostan": 24 }, + { "id": 608, "name": "دلوار", "ostan": 7 }, + { "id": 609, "name": "دلیجان", "ostan": 28 }, + { "id": 610, "name": "دماوند", "ostan": 8 }, + { "id": 611, "name": "دمق", "ostan": 30 }, + { "id": 612, "name": "دندی", "ostan": 14 }, + { "id": 613, "name": "دوبرجی", "ostan": 17 }, + { "id": 614, "name": "دوراهک", "ostan": 7 }, + { "id": 615, "name": "دورود", "ostan": 26 }, + { "id": 616, "name": "دوزدوزان", "ostan": 1 }, + { "id": 617, "name": "دوزه", "ostan": 17 }, + { "id": 618, "name": "دوزین", "ostan": 24 }, + { "id": 619, "name": "دوساری", "ostan": 21 }, + { "id": 620, "name": "دوست محمد", "ostan": 16 }, + { "id": 621, "name": "دوگنبدان", "ostan": 23 }, + { "id": 622, "name": "دولت آباد", "ostan": 11 }, + { "id": 623, "name": "دولت آباد", "ostan": 4 }, + { "id": 624, "name": "ده رییس", "ostan": 16 }, + { "id": 625, "name": "ده سرخ", "ostan": 4 }, + { "id": 626, "name": "دهاقان", "ostan": 4 }, + { "id": 627, "name": "دهبارز", "ostan": 29 }, + { "id": 628, "name": "دهج", "ostan": 21 }, + { "id": 629, "name": "دهدز", "ostan": 13 }, + { "id": 630, "name": "دهدشت", "ostan": 23 }, + { "id": 631, "name": "دهرم", "ostan": 17 }, + { "id": 632, "name": "دهق", "ostan": 4 }, + { "id": 633, "name": "دهگلان", "ostan": 20 }, + { "id": 634, "name": "دهلران", "ostan": 6 }, + { "id": 635, "name": "دیباج", "ostan": 15 }, + { "id": 636, "name": "دیزج دیز", "ostan": 2 }, + { "id": 637, "name": "دیزیچه", "ostan": 4 }, + { "id": 638, "name": "دیشموک", "ostan": 23 }, + { "id": 639, "name": "دیلمان", "ostan": 25 }, + { "id": 640, "name": "دیواندره", "ostan": 20 }, + { "id": 641, "name": "دیهوک", "ostan": 10 }, + { "id": 642, "name": "رابر", "ostan": 21 }, + { "id": 643, "name": "راز", "ostan": 12 }, + { "id": 644, "name": "رازقان", "ostan": 28 }, + { "id": 645, "name": "رازمیان", "ostan": 18 }, + { "id": 646, "name": "راسک", "ostan": 16 }, + { "id": 647, "name": "رامجرد", "ostan": 17 }, + { "id": 648, "name": "رامسر", "ostan": 27 }, + { "id": 649, "name": "رامشیر", "ostan": 13 }, + { "id": 650, "name": "رامهرمز", "ostan": 13 }, + { "id": 651, "name": "رامیان", "ostan": 24 }, + { "id": 652, "name": "رانکوه", "ostan": 25 }, + { "id": 653, "name": "راور", "ostan": 21 }, + { "id": 654, "name": "راین", "ostan": 21 }, + { "id": 655, "name": "ری", "ostan": 8 }, + { "id": 656, "name": "رباط", "ostan": 22 }, + { "id": 657, "name": "رباط سنگ", "ostan": 11 }, + { "id": 658, "name": "رباطکریم", "ostan": 8 }, + { "id": 659, "name": "ربط", "ostan": 2 }, + { "id": 660, "name": "رحیم آباد", "ostan": 25 }, + { "id": 661, "name": "رزن", "ostan": 30 }, + { "id": 662, "name": "رزوه", "ostan": 4 }, + { "id": 663, "name": "رستاق", "ostan": 17 }, + { "id": 664, "name": "رستم آباد", "ostan": 25 }, + { "id": 665, "name": "رستمکلا", "ostan": 27 }, + { "id": 666, "name": "رشت", "ostan": 25 }, + { "id": 672, "name": "رشتخوار", "ostan": 11 }, + { "id": 673, "name": "رضوانشهر", "ostan": 25 }, + { "id": 674, "name": "رضوانشهر", "ostan": 4 }, + { "id": 675, "name": "رضویه", "ostan": 11 }, + { "id": 676, "name": "رضی", "ostan": 3 }, + { "id": 677, "name": "رفسنجان", "ostan": 21 }, + { "id": 678, "name": "رفیع", "ostan": 13 }, + { "id": 679, "name": "روانسر", "ostan": 22 }, + { "id": 680, "name": "رود زرد ماشین", "ostan": 13 }, + { "id": 681, "name": "روداب", "ostan": 11 }, + { "id": 682, "name": "رودبار", "ostan": 25 }, + { "id": 683, "name": "رودبار", "ostan": 21 }, + { "id": 684, "name": "رودبنه", "ostan": 25 }, + { "id": 685, "name": "رودسر", "ostan": 25 }, + { "id": 686, "name": "رودهن", "ostan": 8 }, + { "id": 687, "name": "رودیان", "ostan": 15 }, + { "id": 688, "name": "رونیز", "ostan": 17 }, + { "id": 689, "name": "رویان", "ostan": 27 }, + { "id": 690, "name": "رویدر", "ostan": 29 }, + { "id": 691, "name": "ریجاب", "ostan": 22 }, + { "id": 692, "name": "ریحان", "ostan": 21 }, + { "id": 693, "name": "ریز", "ostan": 7 }, + { "id": 694, "name": "ریگ ملک", "ostan": 16 }, + { "id": 695, "name": "رینه", "ostan": 27 }, + { "id": 696, "name": "ریواده", "ostan": 11 }, + { "id": 697, "name": "ریوش", "ostan": 11 }, + { "id": 698, "name": "زابل", "ostan": 16 }, + { "id": 699, "name": "زارچ", "ostan": 31 }, + { "id": 700, "name": "زازران", "ostan": 4 }, + { "id": 701, "name": "زاغه", "ostan": 26 }, + { "id": 702, "name": "زاووت", "ostan": 13 }, + { "id": 703, "name": "زاویه", "ostan": 28 }, + { "id": 704, "name": "زاهدان", "ostan": 16 }, + { "id": 710, "name": "زاهدشهر", "ostan": 17 }, + { "id": 711, "name": "زاینده رود", "ostan": 4 }, + { "id": 712, "name": "زرآباد", "ostan": 2 }, + { "id": 713, "name": "زرآباد", "ostan": 16 }, + { "id": 714, "name": "زرقان", "ostan": 17 }, + { "id": 715, "name": "زرگرمحله", "ostan": 27 }, + { "id": 716, "name": "زرند", "ostan": 21 }, + { "id": 717, "name": "زرنق", "ostan": 1 }, + { "id": 718, "name": "زرنه", "ostan": 6 }, + { "id": 719, "name": "زرین آباد", "ostan": 14 }, + { "id": 720, "name": "زرین رود", "ostan": 14 }, + { "id": 721, "name": "زرین شهر", "ostan": 4 }, + { "id": 722, "name": "زرینه", "ostan": 20 }, + { "id": 723, "name": "زنجان", "ostan": 14 }, + { "id": 727, "name": "زنگنه", "ostan": 30 }, + { "id": 728, "name": "زنگی آباد", "ostan": 21 }, + { "id": 729, "name": "زنوز", "ostan": 1 }, + { "id": 730, "name": "زواره", "ostan": 4 }, + { "id": 731, "name": "زهان", "ostan": 10 }, + { "id": 732, "name": "زهرا", "ostan": 3 }, + { "id": 733, "name": "زهره", "ostan": 13 }, + { "id": 734, "name": "زهک", "ostan": 16 }, + { "id": 735, "name": "زهکلوت", "ostan": 21 }, + { "id": 736, "name": "زیار", "ostan": 4 }, + { "id": 737, "name": "زیارت", "ostan": 12 }, + { "id": 738, "name": "زیارتعلی", "ostan": 29 }, + { "id": 739, "name": "زیباشهر", "ostan": 4 }, + { "id": 740, "name": "زیدآباد", "ostan": 21 }, + { "id": 741, "name": "زیرآب", "ostan": 27 }, + { "id": 742, "name": "ساری", "ostan": 27 }, + { "id": 743, "name": "ساربوک", "ostan": 16 }, + { "id": 744, "name": "ساروق", "ostan": 28 }, + { "id": 748, "name": "سالند", "ostan": 13 }, + { "id": 749, "name": "سامان", "ostan": 9 }, + { "id": 750, "name": "سامن", "ostan": 30 }, + { "id": 751, "name": "ساوه", "ostan": 28 }, + { "id": 754, "name": "سبزوار", "ostan": 11 }, + { "id": 757, "name": "سپیددشت", "ostan": 26 }, + { "id": 758, "name": "سجاس", "ostan": 14 }, + { "id": 759, "name": "سجزی", "ostan": 4 }, + { "id": 760, "name": "سده", "ostan": 17 }, + { "id": 761, "name": "سده لنجان", "ostan": 4 }, + { "id": 762, "name": "سراب", "ostan": 1 }, + { "id": 763, "name": "سراب باغ", "ostan": 6 }, + { "id": 764, "name": "سراب دوره", "ostan": 26 }, + { "id": 765, "name": "سرابله", "ostan": 6 }, + { "id": 766, "name": "سراوان", "ostan": 16 }, + { "id": 767, "name": "سرایان", "ostan": 10 }, + { "id": 768, "name": "سرباز", "ostan": 16 }, + { "id": 769, "name": "سربیشه", "ostan": 10 }, + { "id": 770, "name": "سرپل ذهاب", "ostan": 22 }, + { "id": 771, "name": "سرخرود", "ostan": 27 }, + { "id": 772, "name": "سرخس", "ostan": 11 }, + { "id": 773, "name": "سرخنکلاته", "ostan": 24 }, + { "id": 774, "name": "سرخون", "ostan": 9 }, + { "id": 775, "name": "سرخه", "ostan": 15 }, + { "id": 776, "name": "سرداران", "ostan": 13 }, + { "id": 777, "name": "سردرود", "ostan": 1 }, + { "id": 778, "name": "سردشت", "ostan": 2 }, + { "id": 779, "name": "سردشت", "ostan": 13 }, + { "id": 780, "name": "سردشت", "ostan": 9 }, + { "id": 781, "name": "سردشت", "ostan": 29 }, + { "id": 782, "name": "سرعین", "ostan": 3 }, + { "id": 783, "name": "سرفاریاب", "ostan": 23 }, + { "id": 784, "name": "سرکان", "ostan": 30 }, + { "id": 785, "name": "سرگز", "ostan": 29 }, + { "id": 786, "name": "سرمست", "ostan": 22 }, + { "id": 787, "name": "سرو", "ostan": 2 }, + { "id": 788, "name": "سروآباد", "ostan": 20 }, + { "id": 789, "name": "سروستان", "ostan": 17 }, + { "id": 790, "name": "سریش آباد", "ostan": 20 }, + { "id": 791, "name": "سطر", "ostan": 22 }, + { "id": 792, "name": "سعادت شهر", "ostan": 17 }, + { "id": 793, "name": "سعد آباد", "ostan": 7 }, + { "id": 794, "name": "سفیددشت", "ostan": 9 }, + { "id": 795, "name": "سفیدسنگ", "ostan": 11 }, + { "id": 796, "name": "سفیدشهر", "ostan": 4 }, + { "id": 797, "name": "سقز", "ostan": 20 }, + { "id": 798, "name": "سگزآباد", "ostan": 18 }, + { "id": 799, "name": "سلامی", "ostan": 11 }, + { "id": 800, "name": "سلطان آباد", "ostan": 13 }, + { "id": 801, "name": "سلطان آباد", "ostan": 11 }, + { "id": 802, "name": "سلطان شهر", "ostan": 17 }, + { "id": 803, "name": "سلطانیه", "ostan": 14 }, + { "id": 804, "name": "سلفچگان", "ostan": 19 }, + { "id": 805, "name": "سلماس", "ostan": 2 }, + { "id": 806, "name": "سلمان شهر", "ostan": 27 }, + { "id": 807, "name": "سماله", "ostan": 13 }, + { "id": 808, "name": "سمنان", "ostan": 15 }, + { "id": 809, "name": "سمیرم", "ostan": 4 }, + { "id": 810, "name": "سمیع آباد", "ostan": 11 }, + { "id": 811, "name": "سنته", "ostan": 20 }, + { "id": 812, "name": "سنخواست", "ostan": 12 }, + { "id": 813, "name": "سندرک", "ostan": 29 }, + { "id": 814, "name": "سنقر", "ostan": 22 }, + { "id": 815, "name": "سنگان", "ostan": 11 }, + { "id": 816, "name": "سنگدوین", "ostan": 24 }, + { "id": 817, "name": "سنگر", "ostan": 25 }, + { "id": 818, "name": "سنندج", "ostan": 20 }, + { "id": 822, "name": "سودجان", "ostan": 9 }, + { "id": 823, "name": "سوران", "ostan": 16 }, + { "id": 824, "name": "سورشجان", "ostan": 9 }, + { "id": 825, "name": "سورک", "ostan": 27 }, + { "id": 826, "name": "سورمق", "ostan": 17 }, + { "id": 827, "name": "سوزا", "ostan": 29 }, + { "id": 828, "name": "سوسنگرد", "ostan": 13 }, + { "id": 829, "name": "سوق", "ostan": 23 }, + { "id": 830, "name": "سومار", "ostan": 22 }, + { "id": 831, "name": "سه قلعه", "ostan": 10 }, + { "id": 832, "name": "سهرورد", "ostan": 14 }, + { "id": 833, "name": "سهند", "ostan": 1 }, + { "id": 834, "name": "سی سخت", "ostan": 23 }, + { "id": 835, "name": "سیاه منصور", "ostan": 13 }, + { "id": 836, "name": "سیاهکل", "ostan": 25 }, + { "id": 837, "name": "سیدان", "ostan": 17 }, + { "id": 838, "name": "سیراف", "ostan": 7 }, + { "id": 839, "name": "سیرجان", "ostan": 21 }, + { "id": 842, "name": "سیردان", "ostan": 18 }, + { "id": 843, "name": "سیرکان", "ostan": 16 }, + { "id": 844, "name": "سیریک", "ostan": 29 }, + { "id": 845, "name": "سیس", "ostan": 1 }, + { "id": 846, "name": "سیلوانه", "ostan": 2 }, + { "id": 847, "name": "سیمین شهر", "ostan": 24 }, + { "id": 848, "name": "سیمینه", "ostan": 2 }, + { "id": 849, "name": "سین", "ostan": 4 }, + { "id": 850, "name": "سیه چشمه", "ostan": 2 }, + { "id": 851, "name": "سیه رود", "ostan": 1 }, + { "id": 852, "name": "شاپورآباد", "ostan": 4 }, + { "id": 853, "name": "شادگان", "ostan": 13 }, + { "id": 854, "name": "شادمهر", "ostan": 11 }, + { "id": 855, "name": "شازند", "ostan": 28 }, + { "id": 856, "name": "شال", "ostan": 18 }, + { "id": 857, "name": "شاندیز", "ostan": 11 }, + { "id": 858, "name": "شاوور", "ostan": 13 }, + { "id": 859, "name": "شاهپوراباد", "ostan": 26 }, + { "id": 860, "name": "شاهدشهر", "ostan": 8 }, + { "id": 861, "name": "شاهدیه", "ostan": 31 }, + { "id": 862, "name": "شاهرود", "ostan": 15 }, + { "id": 863, "name": "شاهو", "ostan": 22 }, + { "id": 864, "name": "شاهین دژ", "ostan": 2 }, + { "id": 865, "name": "شاهین شهر", "ostan": 4 }, + { "id": 866, "name": "شباب", "ostan": 6 }, + { "id": 867, "name": "شبانکاره", "ostan": 7 }, + { "id": 868, "name": "شبستر", "ostan": 1 }, + { "id": 869, "name": "شرافت", "ostan": 13 }, + { "id": 870, "name": "شربیان", "ostan": 1 }, + { "id": 871, "name": "شرفخانه", "ostan": 1 }, + { "id": 872, "name": "شروینه", "ostan": 22 }, + { "id": 873, "name": "شریف آباد", "ostan": 8 }, + { "id": 874, "name": "شریفیه", "ostan": 18 }, + { "id": 875, "name": "ششتمد", "ostan": 11 }, + { "id": 876, "name": "ششده", "ostan": 17 }, + { "id": 877, "name": "شفت", "ostan": 25 }, + { "id": 878, "name": "شلمان", "ostan": 25 }, + { "id": 879, "name": "شلمزار", "ostan": 9 }, + { "id": 880, "name": "شمس آباد", "ostan": 13 }, + { "id": 881, "name": "شمشک", "ostan": 8 }, + { "id": 882, "name": "شنبه", "ostan": 7 }, + { "id": 883, "name": "شندآباد", "ostan": 1 }, + { "id": 884, "name": "شوسف", "ostan": 10 }, + { "id": 885, "name": "شوش", "ostan": 13 }, + { "id": 886, "name": "شوشتر", "ostan": 13 }, + { "id": 887, "name": "شوط", "ostan": 2 }, + { "id": 888, "name": "شوقان", "ostan": 12 }, + { "id": 889, "name": "شول آباد", "ostan": 26 }, + { "id": 890, "name": "شویشه", "ostan": 20 }, + { "id": 891, "name": "شهباز", "ostan": 28 }, + { "id": 892, "name": "شهداد", "ostan": 21 }, + { "id": 893, "name": "شهر امام", "ostan": 13 }, + { "id": 894, "name": "شهراباد", "ostan": 11 }, + { "id": 895, "name": "شهربابک", "ostan": 21 }, + { "id": 896, "name": "شهرپیر", "ostan": 17 }, + { "id": 897, "name": "شهرجدیدهشتگرد", "ostan": 5 }, + { "id": 898, "name": "شهرزو", "ostan": 11 }, + { "id": 899, "name": "شهرصدرا", "ostan": 17 }, + { "id": 900, "name": "شهرضا", "ostan": 4 }, + { "id": 901, "name": "شهرک سرجنگل", "ostan": 16 }, + { "id": 902, "name": "شهرک علی اکبر", "ostan": 16 }, + { "id": 903, "name": "شهرکرد", "ostan": 9 }, + { "id": 904, "name": "شهریار", "ostan": 8 }, + { "id": 908, "name": "شهمیرزاد", "ostan": 15 }, + { "id": 909, "name": "شهیون", "ostan": 13 }, + { "id": 910, "name": "شیبان", "ostan": 13 }, + { "id": 911, "name": "شیراز", "ostan": 17 }, + { "id": 922, "name": "شیرگاه", "ostan": 27 }, + { "id": 923, "name": "شیروان", "ostan": 12 }, + { "id": 924, "name": "شیرود", "ostan": 27 }, + { "id": 925, "name": "شیرین سو", "ostan": 30 }, + { "id": 926, "name": "صاحب", "ostan": 20 }, + { "id": 927, "name": "صادق اباد", "ostan": 24 }, + { "id": 928, "name": "صالح آباد", "ostan": 11 }, + { "id": 929, "name": "صالح آباد", "ostan": 30 }, + { "id": 930, "name": "صالح آباد", "ostan": 6 }, + { "id": 931, "name": "صالح شهر", "ostan": 13 }, + { "id": 932, "name": "صالحیه", "ostan": 8 }, + { "id": 933, "name": "صایین قلعه", "ostan": 14 }, + { "id": 934, "name": "صباشهر", "ostan": 8 }, + { "id": 935, "name": "صحنه", "ostan": 22 }, + { "id": 936, "name": "صغاد", "ostan": 17 }, + { "id": 937, "name": "صفاییه", "ostan": 21 }, + { "id": 938, "name": "صفادشت", "ostan": 8 }, + { "id": 939, "name": "صفاشهر", "ostan": 17 }, + { "id": 940, "name": "صفی آباد", "ostan": 13 }, + { "id": 941, "name": "صفی آباد", "ostan": 12 }, + { "id": 942, "name": "صمصامی", "ostan": 9 }, + { "id": 943, "name": "صوفیان", "ostan": 1 }, + { "id": 944, "name": "صومعه سرا", "ostan": 25 }, + { "id": 945, "name": "صیدون", "ostan": 13 }, + { "id": 946, "name": "ضیاآباد", "ostan": 18 }, + { "id": 947, "name": "طاقانک", "ostan": 9 }, + { "id": 948, "name": "طالخونچه", "ostan": 4 }, + { "id": 949, "name": "طالقان", "ostan": 5 }, + { "id": 950, "name": "طبس", "ostan": 10 }, + { "id": 951, "name": "طبس مسینا", "ostan": 10 }, + { "id": 952, "name": "طبقده", "ostan": 27 }, + { "id": 953, "name": "طرق رود", "ostan": 4 }, + { "id": 954, "name": "طرقبه", "ostan": 11 }, + { "id": 955, "name": "طسوج", "ostan": 17 }, + { "id": 956, "name": "عالی شهر", "ostan": 7 }, + { "id": 957, "name": "عباس اباد", "ostan": 27 }, + { "id": 958, "name": "عجب شیر", "ostan": 1 }, + { "id": 959, "name": "عسگران", "ostan": 4 }, + { "id": 960, "name": "عسلویه", "ostan": 7 }, + { "id": 961, "name": "عشق آباد", "ostan": 11 }, + { "id": 962, "name": "عشق آباد", "ostan": 10 }, + { "id": 963, "name": "عقدا", "ostan": 31 }, + { "id": 964, "name": "علامرودشت", "ostan": 17 }, + { "id": 965, "name": "علویجه", "ostan": 4 }, + { "id": 966, "name": "علی اباد", "ostan": 24 }, + { "id": 967, "name": "عماد شهر", "ostan": 17 }, + { "id": 968, "name": "عنبر", "ostan": 13 }, + { "id": 969, "name": "عنبرآباد", "ostan": 21 }, + { "id": 970, "name": "عنبران", "ostan": 3 }, + { "id": 971, "name": "غرق آباد", "ostan": 28 }, + { "id": 972, "name": "غلامان", "ostan": 12 }, + { "id": 973, "name": "فارسان", "ostan": 9 }, + { "id": 974, "name": "فارغان", "ostan": 29 }, + { "id": 975, "name": "فاروج", "ostan": 12 }, + { "id": 976, "name": "فاریاب", "ostan": 21 }, + { "id": 977, "name": "فاضل آباد", "ostan": 24 }, + { "id": 978, "name": "فامنین", "ostan": 30 }, + { "id": 979, "name": "فتح آباد", "ostan": 4 }, + { "id": 980, "name": "فتح المبین", "ostan": 13 }, + { "id": 981, "name": "فخراباد", "ostan": 3 }, + { "id": 982, "name": "فدامی", "ostan": 17 }, + { "id": 983, "name": "فرادبنه", "ostan": 9 }, + { "id": 984, "name": "فراشبند", "ostan": 17 }, + { "id": 985, "name": "فراغی", "ostan": 24 }, + { "id": 986, "name": "فرح آباد", "ostan": 27 }, + { "id": 987, "name": "فرخ شهر", "ostan": 9 }, + { "id": 988, "name": "فرخی", "ostan": 4 }, + { "id": 989, "name": "فردوس", "ostan": 10 }, + { "id": 990, "name": "فردوسیه", "ostan": 8 }, + { "id": 991, "name": "فردیس", "ostan": 5 }, + { "id": 992, "name": "فرسفج", "ostan": 30 }, + { "id": 993, "name": "فرمهین", "ostan": 28 }, + { "id": 994, "name": "فرون اباد", "ostan": 8 }, + { "id": 995, "name": "فرهادگرد", "ostan": 11 }, + { "id": 996, "name": "فریدونشهر", "ostan": 4 }, + { "id": 997, "name": "فریدونکنار", "ostan": 27 }, + { "id": 998, "name": "فریم", "ostan": 27 }, + { "id": 999, "name": "فریمان", "ostan": 11 }, + { "id": 1000, "name": "فسا", "ostan": 17 }, + { "id": 1001, "name": "فشم", "ostan": 8 }, + { "id": 1002, "name": "فلاورجان", "ostan": 4 }, + { "id": 1003, "name": "فنوج", "ostan": 16 }, + { "id": 1004, "name": "فولادشهر", "ostan": 4 }, + { "id": 1005, "name": "فومن", "ostan": 25 }, + { "id": 1006, "name": "فهرج", "ostan": 21 }, + { "id": 1007, "name": "فیرورق", "ostan": 2 }, + { "id": 1008, "name": "فیروزآباد", "ostan": 17 }, + { "id": 1009, "name": "فیروزآباد", "ostan": 26 }, + { "id": 1010, "name": "فیروزان", "ostan": 30 }, + { "id": 1011, "name": "فیروزکوه", "ostan": 8 }, + { "id": 1012, "name": "فیروزه", "ostan": 11 }, + { "id": 1013, "name": "فیض آباد", "ostan": 11 }, + { "id": 1014, "name": "فیل اباد", "ostan": 9 }, + { "id": 1015, "name": "فین", "ostan": 29 }, + { "id": 1016, "name": "قایم شهر", "ostan": 27 }, + { "id": 1017, "name": "قاین", "ostan": 10 }, + { "id": 1018, "name": "قادراباد", "ostan": 17 }, + { "id": 1019, "name": "قاسم آباد", "ostan": 11 }, + { "id": 1020, "name": "قاضی", "ostan": 12 }, + { "id": 1021, "name": "قایمیه", "ostan": 17 }, + { "id": 1022, "name": "قدس", "ostan": 8 }, + { "id": 1023, "name": "قدمگاه", "ostan": 11 }, + { "id": 1024, "name": "قرچک", "ostan": 8 }, + { "id": 1025, "name": "قرق", "ostan": 24 }, + { "id": 1026, "name": "قرقری", "ostan": 16 }, + { "id": 1027, "name": "قروه", "ostan": 20 }, + { "id": 1028, "name": "قروه درجزین", "ostan": 30 }, + { "id": 1029, "name": "قره آغاج", "ostan": 1 }, + { "id": 1030, "name": "قره بلاغ", "ostan": 17 }, + { "id": 1031, "name": "قره ضیاءالدین", "ostan": 2 }, + { "id": 1032, "name": "قزوین", "ostan": 18 }, + { "id": 1037, "name": "قشم", "ostan": 29 }, + { "id": 1038, "name": "قصابه", "ostan": 3 }, + { "id": 1039, "name": "قصرشیرین", "ostan": 22 }, + { "id": 1040, "name": "قصرقند", "ostan": 16 }, + { "id": 1041, "name": "قطب آباد", "ostan": 17 }, + { "id": 1042, "name": "قطرویه", "ostan": 17 }, + { "id": 1043, "name": "قطور", "ostan": 2 }, + { "id": 1044, "name": "قلعه", "ostan": 22 }, + { "id": 1045, "name": "قلعه تل", "ostan": 13 }, + { "id": 1046, "name": "قلعه خواجه", "ostan": 13 }, + { "id": 1047, "name": "قلعه رییسی", "ostan": 23 }, + { "id": 1048, "name": "قلعه قاضی", "ostan": 29 }, + { "id": 1049, "name": "قلعه گنج", "ostan": 21 }, + { "id": 1050, "name": "قلعه نو", "ostan": 8 }, + { "id": 1051, "name": "قلندرآباد", "ostan": 11 }, + { "id": 1052, "name": "قم", "ostan": 19 }, + { "id": 1061, "name": "قمصر", "ostan": 4 }, + { "id": 1062, "name": "قنوات", "ostan": 19 }, + { "id": 1063, "name": "قوچان", "ostan": 11 }, + { "id": 1064, "name": "قورچی باشی", "ostan": 28 }, + { "id": 1065, "name": "قوشچی", "ostan": 2 }, + { "id": 1066, "name": "قوشخانه", "ostan": 12 }, + { "id": 1067, "name": "قهاوند", "ostan": 30 }, + { "id": 1068, "name": "قهجاورستان", "ostan": 4 }, + { "id": 1069, "name": "قهدریجان", "ostan": 4 }, + { "id": 1070, "name": "قهستان", "ostan": 10 }, + { "id": 1071, "name": "قیام دشت", "ostan": 8 }, + { "id": 1072, "name": "قیدار", "ostan": 14 }, + { "id": 1073, "name": "قیر", "ostan": 17 }, + { "id": 1074, "name": "کاج", "ostan": 9 }, + { "id": 1075, "name": "کاخک", "ostan": 11 }, + { "id": 1076, "name": "کارچان", "ostan": 28 }, + { "id": 1077, "name": "کارزین (فتح آباد)", "ostan": 17 }, + { "id": 1078, "name": "کاریز", "ostan": 11 }, + { "id": 1079, "name": "کازرون", "ostan": 17 }, + { "id": 1080, "name": "کاشان", "ostan": 4 }, + { "id": 1081, "name": "کاشمر", "ostan": 11 }, + { "id": 1082, "name": "کاظم آباد", "ostan": 21 }, + { "id": 1083, "name": "کاکی", "ostan": 7 }, + { "id": 1084, "name": "کامفیروز", "ostan": 17 }, + { "id": 1085, "name": "کامو و چوگان", "ostan": 4 }, + { "id": 1086, "name": "کامیاران", "ostan": 20 }, + { "id": 1087, "name": "کانی دینار", "ostan": 20 }, + { "id": 1088, "name": "کانی سور", "ostan": 20 }, + { "id": 1089, "name": "کبودرآهنگ", "ostan": 30 }, + { "id": 1090, "name": "کتالم وسادات شهر", "ostan": 27 }, + { "id": 1091, "name": "کتیج", "ostan": 16 }, + { "id": 1092, "name": "کجور", "ostan": 27 }, + { "id": 1093, "name": "کدکن", "ostan": 11 }, + { "id": 1094, "name": "کرج", "ostan": 5 }, + { "id": 1106, "name": "کردکوی", "ostan": 24 }, + { "id": 1107, "name": "کرسف", "ostan": 14 }, + { "id": 1108, "name": "کرفس", "ostan": 30 }, + { "id": 1109, "name": "کرکوند", "ostan": 4 }, + { "id": 1110, "name": "کرمان", "ostan": 21 }, + { "id": 1115, "name": "کرمانشاه", "ostan": 22 }, + { "id": 1124, "name": "کرند", "ostan": 22 }, + { "id": 1125, "name": "کره ای", "ostan": 17 }, + { "id": 1126, "name": "کشاورز", "ostan": 2 }, + { "id": 1127, "name": "کشکسرای", "ostan": 1 }, + { "id": 1128, "name": "کشکوییه", "ostan": 21 }, + { "id": 1129, "name": "کلات", "ostan": 11 }, + { "id": 1130, "name": "کلاته", "ostan": 15 }, + { "id": 1131, "name": "کلاته خیج", "ostan": 15 }, + { "id": 1132, "name": "کلاچای", "ostan": 25 }, + { "id": 1133, "name": "کلارآباد", "ostan": 27 }, + { "id": 1134, "name": "کلاردشت", "ostan": 27 }, + { "id": 1135, "name": "کلاله", "ostan": 24 }, + { "id": 1136, "name": "کلمه", "ostan": 7 }, + { "id": 1137, "name": "کلوانق", "ostan": 1 }, + { "id": 1138, "name": "کلور", "ostan": 3 }, + { "id": 1139, "name": "کلیبر", "ostan": 1 }, + { "id": 1140, "name": "کلیشادوسودرجان", "ostan": 4 }, + { "id": 1141, "name": "کمال شهر", "ostan": 5 }, + { "id": 1142, "name": "کمشچه", "ostan": 4 }, + { "id": 1143, "name": "کمه", "ostan": 4 }, + { "id": 1144, "name": "کمیجان", "ostan": 28 }, + { "id": 1145, "name": "کنارتخته", "ostan": 17 }, + { "id": 1146, "name": "کنارک", "ostan": 16 }, + { "id": 1147, "name": "کندر", "ostan": 11 }, + { "id": 1148, "name": "کنگ", "ostan": 29 }, + { "id": 1149, "name": "کنگاور", "ostan": 22 }, + { "id": 1150, "name": "کوار", "ostan": 17 }, + { "id": 1151, "name": "کوپن", "ostan": 17 }, + { "id": 1152, "name": "کوت سیدنعیم", "ostan": 13 }, + { "id": 1153, "name": "کوت عبداله", "ostan": 13 }, + { "id": 1154, "name": "کوچصفهان", "ostan": 25 }, + { "id": 1155, "name": "کوراییم", "ostan": 3 }, + { "id": 1156, "name": "کوزران", "ostan": 22 }, + { "id": 1157, "name": "کوزه کنان", "ostan": 1 }, + { "id": 1158, "name": "کوشک", "ostan": 4 }, + { "id": 1159, "name": "کوشکنار", "ostan": 29 }, + { "id": 1160, "name": "کومله", "ostan": 25 }, + { "id": 1161, "name": "کوهبنان", "ostan": 21 }, + { "id": 1162, "name": "کوهپایه", "ostan": 4 }, + { "id": 1163, "name": "کوهدشت", "ostan": 26 }, + { "id": 1164, "name": "کوهسار", "ostan": 5 }, + { "id": 1165, "name": "کوهستک", "ostan": 29 }, + { "id": 1166, "name": "کوهنانی", "ostan": 26 }, + { "id": 1167, "name": "کوهنجان", "ostan": 17 }, + { "id": 1168, "name": "کوهی خیل", "ostan": 27 }, + { "id": 1169, "name": "کوهین", "ostan": 18 }, + { "id": 1170, "name": "کهریزسنگ", "ostan": 4 }, + { "id": 1171, "name": "کهریزک", "ostan": 8 }, + { "id": 1172, "name": "کهک", "ostan": 19 }, + { "id": 1173, "name": "کهن آباد", "ostan": 15 }, + { "id": 1174, "name": "کهنوج", "ostan": 21 }, + { "id": 1175, "name": "کیاسر", "ostan": 27 }, + { "id": 1176, "name": "کیاشهر", "ostan": 25 }, + { "id": 1177, "name": "کیاکلا", "ostan": 27 }, + { "id": 1178, "name": "کیان", "ostan": 9 }, + { "id": 1179, "name": "کیانشهر", "ostan": 21 }, + { "id": 1180, "name": "کیش", "ostan": 29 }, + { "id": 1181, "name": "کیلان", "ostan": 8 }, + { "id": 1182, "name": "گالیکش", "ostan": 24 }, + { "id": 1183, "name": "گتاب", "ostan": 27 }, + { "id": 1184, "name": "گتوند", "ostan": 13 }, + { "id": 1185, "name": "گراب", "ostan": 26 }, + { "id": 1186, "name": "گراب سفلی", "ostan": 23 }, + { "id": 1187, "name": "گراش", "ostan": 17 }, + { "id": 1188, "name": "گرگاب", "ostan": 4 }, + { "id": 1189, "name": "گرگان", "ostan": 24 }, + { "id": 1193, "name": "گرماب", "ostan": 14 }, + { "id": 1194, "name": "گرمدره", "ostan": 5 }, + { "id": 1195, "name": "گرمسار", "ostan": 15 }, + { "id": 1196, "name": "گرمه", "ostan": 12 }, + { "id": 1197, "name": "گرمی", "ostan": 3 }, + { "id": 1198, "name": "گروک", "ostan": 29 }, + { "id": 1199, "name": "گزبرخوار", "ostan": 4 }, + { "id": 1200, "name": "گزنک", "ostan": 27 }, + { "id": 1201, "name": "گزیک", "ostan": 10 }, + { "id": 1202, "name": "گشت", "ostan": 16 }, + { "id": 1203, "name": "گل تپه", "ostan": 30 }, + { "id": 1204, "name": "گلباف", "ostan": 21 }, + { "id": 1205, "name": "گلبهار", "ostan": 11 }, + { "id": 1206, "name": "گلپایگان", "ostan": 4 }, + { "id": 1207, "name": "گلدشت", "ostan": 4 }, + { "id": 1208, "name": "گلزار", "ostan": 21 }, + { "id": 1209, "name": "گلسار", "ostan": 5 }, + { "id": 1210, "name": "گلستان", "ostan": 8 }, + { "id": 1211, "name": "گلشن", "ostan": 4 }, + { "id": 1212, "name": "گلشهر", "ostan": 4 }, + { "id": 1213, "name": "گلگیر", "ostan": 13 }, + { "id": 1214, "name": "گلمکان", "ostan": 11 }, + { "id": 1215, "name": "گلمورتی", "ostan": 16 }, + { "id": 1216, "name": "گلوگاه", "ostan": 27 }, + { "id": 1217, "name": "گلوگاه", "ostan": 27 }, + { "id": 1218, "name": "گله دار", "ostan": 17 }, + { "id": 1219, "name": "گلیداغ", "ostan": 24 }, + { "id": 1220, "name": "گمیش تپه", "ostan": 24 }, + { "id": 1221, "name": "گناباد", "ostan": 11 }, + { "id": 1222, "name": "گنبدکاووس", "ostan": 24 }, + { "id": 1223, "name": "گنبکی", "ostan": 21 }, + { "id": 1224, "name": "گندمان", "ostan": 9 }, + { "id": 1225, "name": "گوجان", "ostan": 9 }, + { "id": 1226, "name": "گودین", "ostan": 22 }, + { "id": 1227, "name": "گوراب زرمیخ", "ostan": 25 }, + { "id": 1228, "name": "گوریه", "ostan": 13 }, + { "id": 1229, "name": "گوگ تپه", "ostan": 2 }, + { "id": 1230, "name": "گوگان", "ostan": 1 }, + { "id": 1231, "name": "گوگد", "ostan": 4 }, + { "id": 1232, "name": "گوهران", "ostan": 29 }, + { "id": 1233, "name": "گهرو", "ostan": 9 }, + { "id": 1234, "name": "گهواره", "ostan": 22 }, + { "id": 1235, "name": "گیان", "ostan": 30 }, + { "id": 1236, "name": "گیلانغرب", "ostan": 22 }, + { "id": 1237, "name": "گیوی", "ostan": 3 }, + { "id": 1238, "name": "لاجان", "ostan": 2 }, + { "id": 1239, "name": "لار", "ostan": 17 }, + { "id": 1240, "name": "لالجین", "ostan": 30 }, + { "id": 1241, "name": "لاله زار", "ostan": 21 }, + { "id": 1242, "name": "لالی", "ostan": 13 }, + { "id": 1243, "name": "لامرد", "ostan": 17 }, + { "id": 1244, "name": "لاهرود", "ostan": 3 }, + { "id": 1245, "name": "لاهیجان", "ostan": 25 }, + { "id": 1246, "name": "لای بید", "ostan": 4 }, + { "id": 1247, "name": "لپویی", "ostan": 17 }, + { "id": 1248, "name": "لردگان", "ostan": 9 }, + { "id": 1249, "name": "لشت نشاء", "ostan": 25 }, + { "id": 1250, "name": "لطف آباد", "ostan": 11 }, + { "id": 1251, "name": "لطیفی", "ostan": 17 }, + { "id": 1252, "name": "لمزان", "ostan": 29 }, + { "id": 1253, "name": "لنده", "ostan": 23 }, + { "id": 1254, "name": "لنگرود", "ostan": 25 }, + { "id": 1255, "name": "لواسان", "ostan": 8 }, + { "id": 1256, "name": "لوجلی", "ostan": 12 }, + { "id": 1257, "name": "لوشان", "ostan": 25 }, + { "id": 1258, "name": "لولمان", "ostan": 25 }, + { "id": 1259, "name": "لومار", "ostan": 6 }, + { "id": 1260, "name": "لوندویل", "ostan": 25 }, + { "id": 1261, "name": "لیردف", "ostan": 29 }, + { "id": 1262, "name": "لیسار", "ostan": 25 }, + { "id": 1263, "name": "لیکک", "ostan": 23 }, + { "id": 1264, "name": "لیلان", "ostan": 1 }, + { "id": 1265, "name": "مادرسلیمان", "ostan": 17 }, + { "id": 1266, "name": "مادوان", "ostan": 23 }, + { "id": 1267, "name": "مارگون", "ostan": 23 }, + { "id": 1268, "name": "ماژین", "ostan": 6 }, + { "id": 1269, "name": "ماسال", "ostan": 25 }, + { "id": 1270, "name": "ماسوله", "ostan": 25 }, + { "id": 1271, "name": "ماکلوان", "ostan": 25 }, + { "id": 1272, "name": "ماکو", "ostan": 2 }, + { "id": 1273, "name": "مال خلیفه", "ostan": 9 }, + { "id": 1274, "name": "مامونیه", "ostan": 28 }, + { "id": 1275, "name": "ماه نشان", "ostan": 14 }, + { "id": 1276, "name": "ماهان", "ostan": 21 }, + { "id": 1277, "name": "ماهدشت", "ostan": 5 }, + { "id": 1278, "name": "مبارک آباددیز", "ostan": 17 }, + { "id": 1279, "name": "مبارک شهر", "ostan": 1 }, + { "id": 1280, "name": "مبارکه", "ostan": 4 }, + { "id": 1281, "name": "مجلسی", "ostan": 4 }, + { "id": 1282, "name": "مجن", "ostan": 15 }, + { "id": 1283, "name": "محلات", "ostan": 28 }, + { "id": 1284, "name": "محمدآباد", "ostan": 21 }, + { "id": 1285, "name": "محمدآباد", "ostan": 4 }, + { "id": 1286, "name": "محمدآباد", "ostan": 16 }, + { "id": 1287, "name": "محمدان", "ostan": 16 }, + { "id": 1288, "name": "محمدشهر", "ostan": 10 }, + { "id": 1289, "name": "محمدشهر", "ostan": 5 }, + { "id": 1290, "name": "محمدی", "ostan": 16 }, + { "id": 1291, "name": "محمدیار", "ostan": 2 }, + { "id": 1292, "name": "محمدیه", "ostan": 18 }, + { "id": 1293, "name": "محمله", "ostan": 17 }, + { "id": 1294, "name": "محمودآباد", "ostan": 27 }, + { "id": 1295, "name": "محمودآباد", "ostan": 2 }, + { "id": 1296, "name": "محمودآبادنمونه", "ostan": 18 }, + { "id": 1297, "name": "محی آباد", "ostan": 21 }, + { "id": 1298, "name": "مرادلو", "ostan": 3 }, + { "id": 1299, "name": "مراغه", "ostan": 1 }, + { "id": 1302, "name": "مراوه", "ostan": 24 }, + { "id": 1303, "name": "مرجقل", "ostan": 25 }, + { "id": 1304, "name": "مردهک", "ostan": 21 }, + { "id": 1305, "name": "مرزن آباد", "ostan": 27 }, + { "id": 1306, "name": "مرزیکلا", "ostan": 27 }, + { "id": 1307, "name": "مرگنلر", "ostan": 2 }, + { "id": 1308, "name": "مرند", "ostan": 1 }, + { "id": 1309, "name": "مرودشت", "ostan": 17 }, + { "id": 1310, "name": "مروست", "ostan": 31 }, + { "id": 1311, "name": "مریانج", "ostan": 30 }, + { "id": 1312, "name": "مریوان", "ostan": 20 }, + { "id": 1313, "name": "مزایجان", "ostan": 17 }, + { "id": 1314, "name": "مزدآوند", "ostan": 11 }, + { "id": 1315, "name": "مزرعه", "ostan": 24 }, + { "id": 1316, "name": "مس سرچشمه", "ostan": 21 }, + { "id": 1317, "name": "مسجدسلیمان", "ostan": 13 }, + { "id": 1320, "name": "مشراگه", "ostan": 13 }, + { "id": 1321, "name": "مشکات", "ostan": 4 }, + { "id": 1322, "name": "مشکان", "ostan": 17 }, + { "id": 1323, "name": "مشکان", "ostan": 11 }, + { "id": 1324, "name": "مشکین دشت", "ostan": 5 }, + { "id": 1325, "name": "مشگین شهر", "ostan": 3 }, + { "id": 1326, "name": "مشهد", "ostan": 11 }, + { "id": 1328, "name": "مشهد ثامن", "ostan": 11 }, + { "id": 1340, "name": "مشهدریزه", "ostan": 11 }, + { "id": 1341, "name": "مصیری", "ostan": 17 }, + { "id": 1342, "name": "معلم کلایه", "ostan": 18 }, + { "id": 1343, "name": "معمولان", "ostan": 26 }, + { "id": 1344, "name": "مغان سر", "ostan": 3 }, + { "id": 1345, "name": "مقاومت", "ostan": 13 }, + { "id": 1346, "name": "ملاثانی", "ostan": 13 }, + { "id": 1347, "name": "ملارد", "ostan": 8 }, + { "id": 1348, "name": "ملایر", "ostan": 30 }, + { "id": 1349, "name": "ملک آباد", "ostan": 11 }, + { "id": 1350, "name": "ملکان", "ostan": 1 }, + { "id": 1351, "name": "ممقان", "ostan": 1 }, + { "id": 1352, "name": "منج", "ostan": 9 }, + { "id": 1353, "name": "منجیل", "ostan": 25 }, + { "id": 1354, "name": "منصوریه", "ostan": 13 }, + { "id": 1355, "name": "منظریه", "ostan": 4 }, + { "id": 1356, "name": "منوجان", "ostan": 21 }, + { "id": 1357, "name": "موچش", "ostan": 20 }, + { "id": 1358, "name": "مود", "ostan": 10 }, + { "id": 1359, "name": "مورموری", "ostan": 6 }, + { "id": 1360, "name": "موسیان", "ostan": 6 }, + { "id": 1361, "name": "مومن آباد", "ostan": 26 }, + { "id": 1362, "name": "مهاباد", "ostan": 2 }, + { "id": 1363, "name": "مهاباد", "ostan": 4 }, + { "id": 1364, "name": "مهاجران", "ostan": 28 }, + { "id": 1365, "name": "مهاجران", "ostan": 30 }, + { "id": 1366, "name": "مهدی شهر", "ostan": 15 }, + { "id": 1367, "name": "مهر", "ostan": 17 }, + { "id": 1368, "name": "مهر", "ostan": 6 }, + { "id": 1369, "name": "مهران", "ostan": 6 }, + { "id": 1370, "name": "مهربان", "ostan": 1 }, + { "id": 1371, "name": "مهردشت", "ostan": 31 }, + { "id": 1372, "name": "مهرستان", "ostan": 16 }, + { "id": 1373, "name": "مهریز", "ostan": 31 }, + { "id": 1374, "name": "میامی", "ostan": 15 }, + { "id": 1375, "name": "میان راهان", "ostan": 22 }, + { "id": 1376, "name": "میاندوآب", "ostan": 2 }, + { "id": 1377, "name": "میانرود", "ostan": 13 }, + { "id": 1378, "name": "میانشهر", "ostan": 17 }, + { "id": 1379, "name": "میانه", "ostan": 1 }, + { "id": 1380, "name": "میبد", "ostan": 31 }, + { "id": 1381, "name": "میداود", "ostan": 13 }, + { "id": 1382, "name": "میرآباد", "ostan": 2 }, + { "id": 1383, "name": "میرجاوه", "ostan": 16 }, + { "id": 1384, "name": "میلاجرد", "ostan": 28 }, + { "id": 1385, "name": "میمند", "ostan": 17 }, + { "id": 1386, "name": "میمه", "ostan": 4 }, + { "id": 1387, "name": "میمه", "ostan": 6 }, + { "id": 1388, "name": "میناب", "ostan": 29 }, + { "id": 1389, "name": "مینودشت", "ostan": 24 }, + { "id": 1390, "name": "مینوشهر", "ostan": 13 }, + { "id": 1391, "name": "نازک علیا", "ostan": 2 }, + { "id": 1392, "name": "ناغان", "ostan": 9 }, + { "id": 1393, "name": "نافچ", "ostan": 9 }, + { "id": 1394, "name": "نالوس", "ostan": 2 }, + { "id": 1395, "name": "نایین", "ostan": 4 }, + { "id": 1396, "name": "نجف آباد", "ostan": 4 }, + { "id": 1397, "name": "نجف شهر", "ostan": 21 }, + { "id": 1398, "name": "نخل تقی", "ostan": 7 }, + { "id": 1399, "name": "ندوشن", "ostan": 31 }, + { "id": 1400, "name": "نراق", "ostan": 28 }, + { "id": 1401, "name": "نرجه", "ostan": 18 }, + { "id": 1402, "name": "نرماشیر", "ostan": 21 }, + { "id": 1403, "name": "نسیم شهر", "ostan": 8 }, + { "id": 1404, "name": "نشتارود", "ostan": 27 }, + { "id": 1405, "name": "نشتیفان", "ostan": 11 }, + { "id": 1406, "name": "نصرآباد", "ostan": 11 }, + { "id": 1407, "name": "نصرآباد", "ostan": 4 }, + { "id": 1408, "name": "نصرت آباد", "ostan": 16 }, + { "id": 1409, "name": "نصیرشهر", "ostan": 8 }, + { "id": 1410, "name": "نطنز", "ostan": 4 }, + { "id": 1411, "name": "نظام شهر", "ostan": 21 }, + { "id": 1412, "name": "نظرآباد", "ostan": 5 }, + { "id": 1413, "name": "نظرکهریزی", "ostan": 1 }, + { "id": 1414, "name": "نقاب", "ostan": 11 }, + { "id": 1415, "name": "نقده", "ostan": 2 }, + { "id": 1416, "name": "نقنه", "ostan": 9 }, + { "id": 1417, "name": "نکا", "ostan": 27 }, + { "id": 1418, "name": "نگار", "ostan": 21 }, + { "id": 1419, "name": "نگور", "ostan": 16 }, + { "id": 1420, "name": "نگین شهر", "ostan": 24 }, + { "id": 1421, "name": "نلاس", "ostan": 2 }, + { "id": 1422, "name": "نمین", "ostan": 3 }, + { "id": 1423, "name": "نوبران", "ostan": 28 }, + { "id": 1424, "name": "نوبندگان", "ostan": 17 }, + { "id": 1425, "name": "نوجین", "ostan": 17 }, + { "id": 1426, "name": "نوخندان", "ostan": 11 }, + { "id": 1427, "name": "نودان", "ostan": 17 }, + { "id": 1428, "name": "نودژ", "ostan": 21 }, + { "id": 1429, "name": "نودشه", "ostan": 22 }, + { "id": 1430, "name": "نوده خاندوز", "ostan": 24 }, + { "id": 1431, "name": "نور", "ostan": 27 }, + { "id": 1432, "name": "نورآباد", "ostan": 17 }, + { "id": 1433, "name": "نورآباد", "ostan": 26 }, + { "id": 1434, "name": "نوربهار", "ostan": 14 }, + { "id": 1435, "name": "نوسود", "ostan": 22 }, + { "id": 1436, "name": "نوش آباد", "ostan": 4 }, + { "id": 1437, "name": "نوشهر", "ostan": 27 }, + { "id": 1438, "name": "نوشین", "ostan": 2 }, + { "id": 1439, "name": "نوک آباد", "ostan": 16 }, + { "id": 1440, "name": "نوکنده", "ostan": 24 }, + { "id": 1441, "name": "نهاوند", "ostan": 30 }, + { "id": 1442, "name": "نهبندان", "ostan": 10 }, + { "id": 1443, "name": "نی ریز", "ostan": 17 }, + { "id": 1444, "name": "نیاسر", "ostan": 4 }, + { "id": 1445, "name": "نیر", "ostan": 31 }, + { "id": 1446, "name": "نیر", "ostan": 3 }, + { "id": 1447, "name": "نیشابور", "ostan": 11 }, + { "id": 1450, "name": "نیک آباد", "ostan": 4 }, + { "id": 1451, "name": "نیک پی", "ostan": 14 }, + { "id": 1452, "name": "نیک شهر", "ostan": 16 }, + { "id": 1453, "name": "نیل شهر", "ostan": 11 }, + { "id": 1454, "name": "نیمبلوک", "ostan": 10 }, + { "id": 1455, "name": "نیمور", "ostan": 28 }, + { "id": 1456, "name": "واجارگاه", "ostan": 25 }, + { "id": 1457, "name": "وایقان", "ostan": 1 }, + { "id": 1458, "name": "وحدتیه", "ostan": 7 }, + { "id": 1459, "name": "وحیدیه", "ostan": 8 }, + { "id": 1460, "name": "ورامین", "ostan": 8 }, + { "id": 1461, "name": "وراوی", "ostan": 17 }, + { "id": 1462, "name": "وردنجان", "ostan": 9 }, + { "id": 1463, "name": "ورزقان", "ostan": 1 }, + { "id": 1464, "name": "ورزنه", "ostan": 4 }, + { "id": 1465, "name": "ورنامخواست", "ostan": 4 }, + { "id": 1466, "name": "وزوان", "ostan": 4 }, + { "id": 1467, "name": "ونک", "ostan": 4 }, + { "id": 1468, "name": "ویس", "ostan": 13 }, + { "id": 1469, "name": "ویسیان", "ostan": 26 }, + { "id": 1470, "name": "هادی شهر", "ostan": 27 }, + { "id": 1471, "name": "هادیشهر", "ostan": 1 }, + { "id": 1472, "name": "هارونی", "ostan": 9 }, + { "id": 1473, "name": "هجدک", "ostan": 21 }, + { "id": 1474, "name": "هچیرود", "ostan": 27 }, + { "id": 1475, "name": "هرات", "ostan": 31 }, + { "id": 1476, "name": "هرسین", "ostan": 22 }, + { "id": 1477, "name": "هرمز", "ostan": 29 }, + { "id": 1478, "name": "هرند", "ostan": 4 }, + { "id": 1479, "name": "هریس", "ostan": 1 }, + { "id": 1480, "name": "هزارکانیان", "ostan": 20 }, + { "id": 1481, "name": "هشتبندی", "ostan": 29 }, + { "id": 1482, "name": "هشتپر (تالش)", "ostan": 25 }, + { "id": 1483, "name": "هشتجین", "ostan": 3 }, + { "id": 1484, "name": "هشترود", "ostan": 1 }, + { "id": 1485, "name": "هشتگرد", "ostan": 5 }, + { "id": 1486, "name": "هفت چشمه", "ostan": 26 }, + { "id": 1487, "name": "هفتگل", "ostan": 13 }, + { "id": 1488, "name": "هفشجان", "ostan": 9 }, + { "id": 1489, "name": "هلشی", "ostan": 22 }, + { "id": 1490, "name": "هماشهر", "ostan": 17 }, + { "id": 1491, "name": "هماشهر", "ostan": 21 }, + { "id": 1492, "name": "همت آباد", "ostan": 11 }, + { "id": 1493, "name": "همدان", "ostan": 30 }, + { "id": 1498, "name": "هندودر", "ostan": 28 }, + { "id": 1499, "name": "هندیجان", "ostan": 13 }, + { "id": 1500, "name": "هنزا", "ostan": 21 }, + { "id": 1501, "name": "هوراند", "ostan": 1 }, + { "id": 1502, "name": "هوره", "ostan": 9 }, + { "id": 1503, "name": "هویزه", "ostan": 13 }, + { "id": 1504, "name": "هیدج", "ostan": 14 }, + { "id": 1505, "name": "هیدوچ", "ostan": 16 }, + { "id": 1506, "name": "هیر", "ostan": 3 }, + { "id": 1507, "name": "یاسوج", "ostan": 23 }, + { "id": 1508, "name": "یاسوکند", "ostan": 20 }, + { "id": 1509, "name": "یامچی", "ostan": 1 }, + { "id": 1510, "name": "یان چشمه", "ostan": 9 }, + { "id": 1511, "name": "یزد", "ostan": 31 }, + { "id": 1515, "name": "یزدان شهر", "ostan": 21 }, + { "id": 1516, "name": "یکه سعود", "ostan": 12 }, + { "id": 1517, "name": "یولاگلدی", "ostan": 2 }, + { "id": 1518, "name": "یونسی", "ostan": 11 } +] diff --git a/src/db/data/product.ts b/src/db/data/product.ts new file mode 100644 index 0000000..5e3d7e3 --- /dev/null +++ b/src/db/data/product.ts @@ -0,0 +1,671 @@ +import { faker } from "@faker-js/faker"; + +import { CreateProductStep, ProductSource, ProductStatus } from "../../common/enums/product.enum"; +import { SellerType } from "../../common/enums/seller.enum"; +import { OwnerRef } from "../../modules/shop/models/Abstraction/IShop"; + +faker.locale = "fa"; +export const fakeImageGenerator = () => faker.image.imageUrl(); +export const CreateFakeSellerData = () => ({ + fullName: faker.name.fullName(), + shebaNumber: faker.finance.iban(), + dateOfBirth: "1360/08/25", + phoneNumber: faker.phone.number("0912#######"), + telephoneNumber: faker.phone.number("0863#######"), + nationalCode: faker.random.numeric(10), + email: faker.internet.email(crypto.randomUUID().slice(0, 5)), + profilePhoto: faker.image.abstract(), + type: faker.helpers.arrayElement([SellerType.RETAILER, SellerType.WHOLESALER]), +}); + +export const createFakeShopData = (shipmentMethod: number[], owner: string) => ({ + shopName: `فروشگاه ${faker.commerce.department()}`, + shopDescription: "تست", + telephoneNumber: faker.phone.number("0863#######"), + shopNationalId: faker.random.numeric(10), + shopPostalCode: faker.random.numeric(10), + logo: faker.image.imageUrl(), + rate: { + totalRate: faker.datatype.number({ min: 1, max: 5 }), + totalCount: faker.datatype.number({ min: 1, max: 1000 }), + onTimeShip: faker.datatype.number({ min: 1, max: 5 }), + returns: faker.datatype.number({ min: 1, max: 5 }), + cancel: faker.datatype.number({ min: 1, max: 5 }), + }, + shipmentMethod, + owner, + ownerRef: OwnerRef.SELLER, +}); + +export const fakeProductsData = [ + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "تلویزیون ال ای دی سامسونگ", + // title_en: "Samsung LED TV", + // model: "SAMSUNG123", + // source: ProductSource.LOCAL, + // description: "تلویزیون ال ای دی 55 اینچ سامسونگ با کیفیت تصویر 4K", + // metaDescription: "تلویزیون ال ای دی سامسونگ 4K", + // tags: ["ال ای دی", "سامسونگ", "4K"], + // advantages: ["کیفیت تصویر عالی", "قاب باریک"], + // disAdvantages: ["قیمت بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "ماشین لباسشویی ال جی", + // title_en: "LG Washing Machine", + // model: "LGWASH456", + // source: ProductSource.IMPORT, + // description: "ماشین لباسشویی 8 کیلوگرمی ال جی با تکنولوژی بخار شوی", + // metaDescription: "ماشین لباسشویی ال جی با ظرفیت 8 کیلوگرم", + // tags: ["لباسشویی", "ال جی", "بخار شوی"], + // advantages: ["مصرف انرژی کم", "سرعت چرخش بالا"], + // disAdvantages: ["صدا در حالت خشک کن"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "یخچال ساید بای ساید بوش", + // title_en: "Bosch Side by Side Refrigerator", + // model: "BOSCHFRIDGE789", + // source: ProductSource.LOCAL, + // description: "یخچال فریزر ساید بای ساید بوش با ظرفیت 30 فوت", + // metaDescription: "یخچال فریزر ساید بای ساید بوش", + // tags: ["یخچال", "بوش", "ساید بای ساید"], + // advantages: ["ظرفیت بالا", "طراحی مدرن"], + // disAdvantages: ["قیمت بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + { + color: true, + size: false, + meterage: false, + title_fa: "موبایل آیفون 13", + title_en: "iPhone 13", + model: "IPHONE13PRO", + source: ProductSource.IMPORT, + description: "گوشی آیفون 13 پرو با حافظه 256 گیگابایت و دوربین سه‌گانه", + metaDescription: "آیفون 13 پرو با حافظه 256 گیگ", + tags: ["آیفون", "اپل", "موبایل"], + advantages: ["دوربین عالی", "عملکرد سریع"], + disAdvantages: ["قیمت بسیار بالا"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: true, + size: false, + meterage: false, + title_fa: "لپ‌تاپ ایسوس", + title_en: "Asus Laptop", + model: "ASUSLAP234", + source: ProductSource.LOCAL, + description: "لپ‌تاپ ایسوس با پردازنده Core i7 و حافظه 16 گیگابایت", + metaDescription: "لپ‌تاپ ایسوس با حافظه 16 گیگ", + tags: ["لپ‌تاپ", "ایسوس", "Core i7"], + advantages: ["عملکرد قوی", "صفحه‌نمایش با کیفیت"], + disAdvantages: ["عمر باتری پایین"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: true, + size: false, + meterage: false, + title_fa: "ساعت هوشمند اپل واچ", + title_en: "Apple Watch", + model: "APPLEWATCH123", + source: ProductSource.IMPORT, + description: "ساعت هوشمند اپل با قابلیت اندازه‌گیری ضربان قلب", + metaDescription: "اپل واچ سری 6", + tags: ["ساعت هوشمند", "اپل", "واچ"], + advantages: ["اندازه‌گیری سلامت", "طراحی زیبا"], + disAdvantages: ["عمر باتری محدود"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "تبلت سامسونگ گلکسی تب", + // title_en: "Samsung Galaxy Tab", + // model: "SAMGALTAB567", + // source: ProductSource.LOCAL, + // description: "تبلت سامسونگ گلکسی تب با صفحه نمایش 10.5 اینچ", + // metaDescription: "تبلت سامسونگ گلکسی", + // tags: ["تبلت", "سامسونگ", "گلکسی"], + // advantages: ["صفحه نمایش بزرگ", "قابلیت چند وظیفگی"], + // disAdvantages: ["عمر باتری متوسط"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "دوربین عکاسی نیکون", + // title_en: "Nikon Camera", + // model: "NIKONCAM123", + // source: ProductSource.IMPORT, + // description: "دوربین دیجیتال نیکون با سنسور 24 مگاپیکسل", + // metaDescription: "دوربین نیکون دیجیتال", + // tags: ["دوربین", "نیکون", "دیجیتال"], + // advantages: ["کیفیت تصویر بالا", "سنسور قوی"], + // disAdvantages: ["بدنه سنگین"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "اسپیکر بلوتوثی جی بی ال", + // title_en: "JBL Bluetooth Speaker", + // model: "JBLSPK789", + // source: ProductSource.LOCAL, + // description: "اسپیکر بلوتوثی جی بی ال با کیفیت صدای عالی", + // metaDescription: "اسپیکر جی بی ال بلوتوث", + // tags: ["اسپیکر", "بلوتوث", "جی بی ال"], + // advantages: ["کیفیت صدای عالی", "قابلیت حمل آسان"], + // disAdvantages: ["قیمت نسبتاً بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "هدفون بی سیم سونی", + // title_en: "Sony Wireless Headphones", + // model: "SONYWH123", + // source: ProductSource.IMPORT, + // description: "هدفون بی سیم سونی با قابلیت حذف نویز", + // metaDescription: "هدفون بی سیم سونی", + // tags: ["هدفون", "بی سیم", "سونی"], + // advantages: ["حذف نویز عالی", "طراحی ارگونومیک"], + // disAdvantages: ["قیمت بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "دوربین فیلمبرداری سونی", + // title_en: "Sony Camcorder", + // model: "SONYCAM456", + // source: ProductSource.LOCAL, + // description: "دوربین فیلمبرداری سونی با قابلیت فیلم‌برداری 4K", + // metaDescription: "دوربین فیلمبرداری 4K سونی", + // tags: ["دوربین", "فیلمبرداری", "سونی"], + // advantages: ["فیلم‌برداری 4K", "باتری با عمر طولانی"], + // disAdvantages: ["وزن سنگین"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "گوشی شیائومی می 11", + // title_en: "Xiaomi Mi 11", + // model: "MI11PRO", + // source: ProductSource.IMPORT, + // description: "گوشی موبایل شیائومی می 11 پرو با حافظه 256 گیگابایت", + // metaDescription: "گوشی شیائومی می 11 پرو", + // tags: ["شیائومی", "موبایل", "می 11"], + // advantages: ["دوربین قوی", "پردازنده سریع"], + // disAdvantages: ["شارژر جداگانه فروخته می‌شود"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "تلویزیون ال جی OLED", + // title_en: "LG OLED TV", + // model: "LGOLED789", + // source: ProductSource.LOCAL, + // description: "تلویزیون 65 اینچ ال جی با کیفیت تصویر OLED", + // metaDescription: "تلویزیون OLED ال جی", + // tags: ["تلویزیون", "ال جی", "OLED"], + // advantages: ["کیفیت تصویر عالی", "طراحی باریک"], + // disAdvantages: ["قیمت بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "پاوربانک انکر", + // title_en: "Anker Power Bank", + // model: "ANKERPB100", + // source: ProductSource.IMPORT, + // description: "پاوربانک انکر با ظرفیت 20000 میلی‌آمپر ساعت", + // metaDescription: "پاوربانک انکر با ظرفیت بالا", + // tags: ["پاوربانک", "انکر", "ظرفیت بالا"], + // advantages: ["ظرفیت بزرگ", "شارژ سریع"], + // disAdvantages: ["وزن بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: false, + // size: false, + // meterage: false, + // title_fa: "کنسول بازی پلی استیشن 5", + // title_en: "PlayStation 5", + // model: "PS5DISC", + // source: ProductSource.IMPORT, + // description: "کنسول بازی پلی استیشن 5 با دیسک درایو", + // metaDescription: "کنسول بازی پلی استیشن 5", + // tags: ["کنسول", "پلی استیشن", "بازی"], + // advantages: ["بازی‌های انحصاری", "گرافیک فوق‌العاده"], + // disAdvantages: ["موجودی کم"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "هدفون بی سیم بوز", + // title_en: "Bose Wireless Headphones", + // model: "BOSEWH789", + // source: ProductSource.IMPORT, + // description: "هدفون بی سیم بوز با قابلیت حذف نویز فعال", + // metaDescription: "هدفون بی سیم بوز", + // tags: ["هدفون", "بی سیم", "بوز"], + // advantages: ["کیفیت صدای بالا", "حذف نویز"], + // disAdvantages: ["قیمت بالا"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "تبلت هواوی مدیاپد", + // title_en: "Huawei MediaPad", + // model: "HUAWEITAB1", + // source: ProductSource.LOCAL, + // description: "تبلت هواوی مدیاپد با صفحه نمایش 10.8 اینچی", + // metaDescription: "تبلت هواوی مدیاپد", + // tags: ["تبلت", "هواوی", "مدیاپد"], + // advantages: ["صفحه نمایش بزرگ", "طراحی سبک"], + // disAdvantages: ["عملکرد متوسط در بازی"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "اسپیکر بلوتوثی سونی", + // title_en: "Sony Bluetooth Speaker", + // model: "SONYSPK234", + // source: ProductSource.IMPORT, + // description: "اسپیکر بلوتوثی سونی با کیفیت صدای عالی", + // metaDescription: "اسپیکر بلوتوثی سونی", + // tags: ["اسپیکر", "بلوتوث", "سونی"], + // advantages: ["صدای شفاف", "طراحی مقاوم در برابر آب"], + // disAdvantages: ["عمر باتری محدود"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "موبایل نوکیا G20", + // title_en: "Nokia G20", + // model: "NOKIAG20", + // source: ProductSource.LOCAL, + // description: "گوشی موبایل نوکیا G20 با حافظه 128 گیگابایت", + // metaDescription: "گوشی نوکیا G20", + // tags: ["نوکیا", "موبایل", "G20"], + // advantages: ["باتری بادوام", "طراحی مقاوم"], + // disAdvantages: ["دوربین متوسط"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: true, + // size: false, + // meterage: false, + // title_fa: "مایکروویو پاناسونیک", + // title_en: "Panasonic Microwave", + // model: "PANAMW234", + // source: ProductSource.IMPORT, + // description: "مایکروویو پاناسونیک با قدرت 1200 وات", + // metaDescription: "مایکروویو پاناسونیک", + // tags: ["مایکروویو", "پاناسونیک", "پخت سریع"], + // advantages: ["پخت سریع", "قابلیت گریل"], + // disAdvantages: ["فضای داخلی کوچک"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + { + color: false, + size: true, + meterage: false, + title_fa: "تی شرت مردانه", + title_en: "mardane t-shirt", + model: "Nike", + source: ProductSource.IMPORT, + description: "تی شرت سبک و راحت مردانه نایک", + metaDescription: "تی شرت مردانه نایک برای ورزش و استفاده روزمره", + tags: ["تی شرت", "نایک", "لباس مردانه"], + advantages: ["پارچه سبک", "مناسب برای تابستان"], + disAdvantages: ["محدودیت رنگ"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: false, + size: false, + meterage: true, + title_fa: "فرش ماشینی", + title_en: "farsh mashini", + model: "Iranian Classic", + source: ProductSource.LOCAL, + description: "فرش ماشینی با طرح کلاسیک ایرانی", + metaDescription: "فرش ماشینی با کیفیت بالا و طرح سنتی ایرانی", + tags: ["فرش", "ماشینی", "کلاسیک"], + advantages: ["طرح زیبا", "قابلیت شستشو"], + disAdvantages: ["سنگین بودن"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: false, + size: true, + meterage: false, + title_fa: "شلوار جین زنانه", + title_en: "zanane jean pants", + model: "Levi's", + source: ProductSource.IMPORT, + description: "شلوار جین زنانه با استایل اسپرت", + metaDescription: "شلوار جین زنانه مناسب برای استفاده روزمره", + tags: ["شلوار جین", "زنانه", "لباس"], + advantages: ["کیفیت بالا", "راحتی"], + disAdvantages: ["قیمت بالا"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: false, + size: true, + meterage: false, + title_fa: "پیراهن مجلسی زنانه", + title_en: "zanane dress", + model: "Zara", + source: ProductSource.IMPORT, + description: "پیراهن مجلسی شیک زنانه برند زارا", + metaDescription: "پیراهن زنانه شیک برای مجالس و مهمانی‌ها", + tags: ["پیراهن", "مجلسی", "زنانه", "زارا"], + advantages: ["مدل زیبا", "جنس با کیفیت"], + disAdvantages: ["حساس به شستشو"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: false, + size: false, + meterage: true, + title_fa: "پرده اتاق خواب", + title_en: "bedroom curtain", + model: "LuxDecor", + source: ProductSource.LOCAL, + description: "پرده اتاق خواب با طرح مدرن و زیبا", + metaDescription: "پرده اتاق خواب شیک و جذاب با جنس مقاوم", + tags: ["پرده", "اتاق خواب", "دکوراسیون"], + advantages: ["نورگیر خوب", "نصب آسان"], + disAdvantages: ["قابلیت چین‌خوردگی"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + // { + // color: false, + // size: true, + // meterage: false, + // title_fa: "کفش ورزشی مردانه", + // title_en: "sport shoes", + // model: "Adidas", + // source: ProductSource.IMPORT, + // description: "کفش ورزشی مردانه مناسب دویدن", + // metaDescription: "کفش ورزشی سبک و راحت برای فعالیت‌های ورزشی", + // tags: ["کفش", "ورزشی", "مردانه", "آدیداس"], + // advantages: ["کفی نرم", "سبک"], + // disAdvantages: ["عدم مقاومت در برابر آب"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + { + color: false, + size: false, + meterage: true, + title_fa: "کفپوش چوبی", + title_en: "wooden flooring", + model: "WoodMaster", + source: ProductSource.LOCAL, + description: "کفپوش چوبی با کیفیت برای اتاق‌ها و سالن", + metaDescription: "کفپوش چوبی مقاوم با ظاهر زیبا", + tags: ["کفپوش", "چوبی", "دکوراسیون"], + advantages: ["زیبایی طبیعی", "مقاومت بالا"], + disAdvantages: ["نیاز به مراقبت ویژه"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { + color: false, + size: true, + meterage: false, + title_fa: "کت چرمی مردانه", + title_en: "leather jacket", + model: "Tommy Hilfiger", + source: ProductSource.IMPORT, + description: "کت چرمی شیک مردانه برند تامی هیلفیگر", + metaDescription: "کت چرمی مردانه با کیفیت و استایل خاص", + tags: ["کت", "چرمی", "مردانه", "تامی"], + advantages: ["گرم و بادوام", "استایل خاص"], + disAdvantages: ["نیاز به مراقبت از چرم"], + isFake: false, + imagesUrl: { + cover: faker.image.imageUrl(), + list: [faker.image.imageUrl(), faker.image.imageUrl()], + }, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + // { + // color: false, + // size: true, + // meterage: false, + // title_fa: "هودی دخترانه", + // title_en: "hoodie", + // model: "Puma", + // source: ProductSource.IMPORT, + // description: "هودی گرم و راحت دخترانه برند پوما", + // metaDescription: "هودی دخترانه مناسب فصل پاییز و زمستان", + // tags: ["هودی", "دخترانه", "پوشاک", "پوما"], + // advantages: ["گرم و نرم", "کلاه قابل تنظیم"], + // disAdvantages: ["محدودیت رنگ"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, + // { + // color: false, + // size: false, + // meterage: true, + // title_fa: "کاغذ دیواری مدرن", + // title_en: "modern wallpaper", + // model: "ArtWall", + // source: ProductSource.IMPORT, + // description: "کاغذ دیواری مدرن با طرح‌های جدید و زیبا", + // metaDescription: "کاغذ دیواری با طرح‌های مدرن برای دکوراسیون داخلی", + // tags: ["کاغذ دیواری", "دکوراسیون", "مدرن"], + // advantages: ["نصب آسان", "قابلیت شستشو"], + // disAdvantages: ["نیاز به دیوار صاف"], + // isFake: false, + // imagesUrl: { + // cover: faker.image.imageUrl(), + // list: [faker.image.imageUrl(), faker.image.imageUrl()], + // }, + // step: CreateProductStep.Image, + // status: ProductStatus.Approved, + // }, +]; diff --git a/src/db/seeders/backfillHierarchy.ts b/src/db/seeders/backfillHierarchy.ts new file mode 100644 index 0000000..da02d1d --- /dev/null +++ b/src/db/seeders/backfillHierarchy.ts @@ -0,0 +1,45 @@ +import mongoose from "mongoose"; + +import { CategoryModel } from "../../modules/category/models/category.model"; + +// Create the Category model + +export async function backfillHierarchy() { + try { + console.log("Starting hierarchy backfill..."); + + // Fetch all categories + const categories = await CategoryModel.find(); + + // Helper function to recursively update hierarchy + const updateHierarchy = async (categoryId: mongoose.Types.ObjectId, parentHierarchy: mongoose.Types.ObjectId[] = []) => { + const category = await CategoryModel.findById(categoryId); + + if (!category) return; + + const currentHierarchy = [...parentHierarchy, category._id]; + + // Update the category's hierarchy + category.hierarchy = currentHierarchy; + await category.save(); + + // Find child categories and update their hierarchy + const childCategories = await CategoryModel.find({ parent: category._id }); + for (const child of childCategories) { + await updateHierarchy(child._id, currentHierarchy); + } + }; + + // Find all top-level categories (categories with no parent) + const topLevelCategories = categories.filter((category) => !category.parent); + + // Update hierarchy for all top-level categories and their descendants + for (const topCategory of topLevelCategories) { + await updateHierarchy(topCategory._id); + } + + console.log("Hierarchy backfill completed successfully!"); + } catch (err) { + console.error("Error during hierarchy backfill:", err); + } +} diff --git a/src/db/seeders/blogCategorySeeder.ts b/src/db/seeders/blogCategorySeeder.ts new file mode 100644 index 0000000..855b853 --- /dev/null +++ b/src/db/seeders/blogCategorySeeder.ts @@ -0,0 +1,41 @@ +import { Logger } from "../../core/logging/logger"; +import { BlogCategoryModel } from "../../modules/blog/models/blogCategory.model"; + +export const blogCategorySeeder = async (logger: Logger) => { + try { + const categories = [ + { + title_fa: "الکترونیک", + slug: "electronics", + description: "آخرین اخبار و مقالات مربوط به محصولات الکترونیکی", + }, + { + title_fa: "مد و پوشاک", + slug: "fashion", + description: "مقالات درباره مد، پوشاک و جدیدترین ترندهای فشن", + }, + { + title_fa: "خانه و آشپزخانه", + slug: "home-kitchen", + description: "راهنمایی‌ها و مقالات مرتبط با محصولات خانه و آشپزخانه", + }, + { + title_fa: "زیبایی و سلامت", + slug: "beauty-health", + description: "مقالات درباره زیبایی، مراقبت از پوست و سلامت", + }, + { + title_fa: "بازی و سرگرمی", + slug: "gaming-entertainment", + description: "آخرین اخبار و مقالات مربوط به بازی‌ها و سرگرمی‌ها", + }, + ]; + + logger.info("start seeding Blog categories."); + + await BlogCategoryModel.insertMany(categories); + logger.info("Blog categories seeded successfully"); + } catch (error) { + logger.error("Error seeding blog categories:", error); + } +}; diff --git a/src/db/seeders/blogSeeder.ts b/src/db/seeders/blogSeeder.ts new file mode 100644 index 0000000..e9a634d --- /dev/null +++ b/src/db/seeders/blogSeeder.ts @@ -0,0 +1,239 @@ +import { Logger } from "../../core/logging/logger"; +import { AdminModel } from "../../modules/admin/models/admin.model"; +import { IBlog } from "../../modules/blog/models/Abstraction/IBlog"; +import { BlogModel } from "../../modules/blog/models/blog.model"; +import { BlogCategoryModel } from "../../modules/blog/models/blogCategory.model"; +import { fakeImageGenerator } from "../data/product"; + +export const blogSeeder = async (logger: Logger) => { + try { + const admin = await AdminModel.findOne({ userName: "defaultAdmin" }); + const categories = await BlogCategoryModel.find(); + + if (categories.length === 0) { + logger.error("No blog categories found. Please run the blogCategorySeeder first."); + throw new Error("No blog categories found. Please run the blogCategorySeeder first."); + } + + const blogs: IBlog[] = []; + + categories.forEach((category) => { + switch (category.slug) { + case "electronics": + blogs.push( + { + title_fa: "برترین گوشی‌های هوشمند سال ۲۰۲۴", + slug: "top-smartphones-2024", + category: category._id, + description: "مروری بر بهترین گوشی‌های هوشمند عرضه شده در سال ۲۰۲۴.", + content: + "در سال ۲۰۲۴، شرکت‌های بزرگ تکنولوژی گوشی‌های هوشمند جدیدی معرفی کردند که شامل ویژگی‌ها و قابلیت‌های پیشرفته‌ای هستند...", + author: admin!._id, + tags: ["گوشی", "هوشمند", "تکنولوژی"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "راهنمای خرید لپ‌تاپ مناسب برای دانشجویان", + slug: "laptop-buying-guide-for-students", + category: category._id, + description: "نکاتی که دانشجویان باید هنگام خرید لپ‌تاپ مد نظر داشته باشند.", + content: + "برای دانشجویان، انتخاب لپ‌تاپ مناسب اهمیت زیادی دارد. عواملی مانند وزن، عمر باتری، قدرت پردازش و قیمت باید در نظر گرفته شوند...", + author: admin!._id, + tags: ["لپ‌تاپ", "خرید", "راهنما"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "تکنولوژی‌های نوین در تلویزیون‌های هوشمند", + slug: "innovative-technologies-in-smart-tvs", + category: category._id, + description: "بررسی آخرین تکنولوژی‌های موجود در تلویزیون‌های هوشمند.", + content: + "تلویزیون‌های هوشمند امروزه با تکنولوژی‌های پیشرفته‌ای مانند 4K، OLED، HDR و سیستم‌های هوش مصنوعی مجهز شده‌اند که تجربه تماشای شما را بهبود می‌بخشند...", + author: admin!._id, + tags: ["تلویزیون", "هوشمند", "تکنولوژی"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + ); + break; + + case "fashion": + blogs.push( + { + title_fa: "جدیدترین ترندهای مد بهار ۲۰۲۴", + slug: "latest-spring-fashion-trends-2024", + category: category._id, + description: "مروری بر جدیدترین ترندهای مد فصل بهار ۲۰۲۴.", + content: + "فصل بهار ۲۰۲۴ با ورود رنگ‌های شاد، الگوهای گلدار و پارچه‌های سبک همراه است. این ساله تاکید بیشتری بر روی راحتی و استایل کلاسیک دیده می‌شود...", + author: admin!._id, + tags: ["مد", "بهار", "ترند"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "چگونه لباس مناسب را برای یک مراسم رسمی انتخاب کنیم", + slug: "choosing-suitable-clothes-for-formal-events", + category: category._id, + description: "نکاتی برای انتخاب لباس مناسب جهت شرکت در مراسم‌های رسمی.", + content: + "انتخاب لباس مناسب برای مراسم‌های رسمی می‌تواند تاثیر زیادی بر روی حضور شما داشته باشد. در این مقاله به بررسی نکاتی که باید هنگام انتخاب لباس رسمی در نظر بگیرید می‌پردازیم...", + author: admin!._id, + tags: ["لباس", "رسمی", "راهنما"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "استایل‌های مردانه محبوب در سال ۲۰۲۴", + slug: "popular-mens-fashion-styles-2024", + category: category._id, + description: "بررسی استایل‌های محبوب و جدید در مد مردانه سال ۲۰۲۴.", + content: + "مد مردانه در سال ۲۰۲۴ با ترکیب کلاسیک و مدرن، سبک‌های متنوعی را ارائه می‌دهد. از کت و شلوارهای شیک گرفته تا استایل‌های کژوال و خیابانی، انتخاب‌های زیادی وجود دارد...", + author: admin!._id, + tags: ["مد", "مردانه", "استایل"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + ); + break; + + case "home-kitchen": + blogs.push( + { + title_fa: "بهترین لوازم آشپزخانه برای هر خانه", + slug: "best-kitchen-appliances-for-every-home", + category: category._id, + description: "نکاتی برای انتخاب بهترین لوازم آشپزخانه مناسب برای خانه‌های مختلف.", + content: + "لوازم آشپزخانه نقش مهمی در راحتی و کارایی خانه دارند. از ماشین لباسشویی گرفته تا فرهای هوشمند، انتخاب صحیح می‌تواند تجربه آشپزی شما را بهبود بخشد...", + author: admin!._id, + tags: ["آشپزخانه", "لوازم", "راهنما"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "نکات طراحی داخلی خانه برای فضاهای کوچک", + slug: "interior-design-tips-for-small-spaces", + category: category._id, + description: "راهنمایی‌هایی برای طراحی داخلی مناسب فضاهای کوچک خانه.", + content: + "طراحی داخلی فضاهای کوچک نیازمند استفاده بهینه از فضا و انتخاب مبلمان چندکاره است. در این مقاله به بررسی نکاتی که می‌تواند به شما در طراحی منزل کوچک کمک کند می‌پردازیم...", + author: admin!._id, + tags: ["طراحی داخلی", "خانه کوچک", "نکات"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "روش‌های سازماندهی و بهینه‌سازی فضای آشپزخانه", + slug: "organizing-and-optimizing-kitchen-space", + category: category._id, + description: "نکاتی برای سازماندهی و بهینه‌سازی فضای آشپزخانه.", + content: + "یک آشپزخانه سازماندهی شده نه تنها زیبایی دارد بلکه کارایی بیشتری نیز دارد. از استفاده از کشوهای چندمنظوره تا نگهداری مناسب ابزارهای آشپزی، این مقاله به شما کمک می‌کند تا آشپزخانه‌تان را بهینه کنید...", + author: admin!._id, + tags: ["آشپزخانه", "سازماندهی", "بهینه‌سازی"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + ); + break; + + case "beauty-health": + blogs.push( + { + title_fa: "رازهای داشتن پوستی سالم و درخشان", + slug: "secrets-to-healthy-glowing-skin", + category: category._id, + description: "نکاتی برای داشتن پوستی سالم، درخشان و زیبا.", + content: + "پوستی سالم نیازمند مراقبت‌های منظم و استفاده از محصولات مناسب است. در این مقاله به بررسی رازهایی که می‌تواند به شما کمک کند پوستی درخشان و زیبا داشته باشید می‌پردازیم...", + author: admin!._id, + tags: ["زیبایی", "سلامت پوست", "راهنما"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "فواید ورزش منظم برای سلامت روانی", + slug: "benefits-of-regular-exercise-for-mental-health", + category: category._id, + description: "بررسی فواید ورزش منظم بر سلامت روانی انسان.", + content: + "ورزش نه تنها به بهبود سلامت جسمی کمک می‌کند بلکه تاثیرات مثبت زیادی بر سلامت روانی دارد. از کاهش استرس تا افزایش اعتماد به نفس، در این مقاله به بررسی این فواید می‌پردازیم...", + author: admin!._id, + tags: ["ورزش", "سلامت روان", "فواید"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "مراقبت از موهای خشک و آسیب‌دیده", + slug: "caring-for-dry-damaged-hair", + category: category._id, + description: "نکاتی برای مراقبت و ترمیم موهای خشک و آسیب‌دیده.", + content: + "موهای خشک و آسیب‌دیده نیازمند مراقبت ویژه‌ای هستند. از استفاده از شامپوهای مرطوب‌کننده تا ماسک‌های تغذیه‌کننده، در این مقاله به بررسی روش‌هایی که می‌تواند به ترمیم و تقویت موهای شما کمک کند می‌پردازیم...", + author: admin!._id, + tags: ["مو", "زیبایی", "مراقبت"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + ); + break; + + case "gaming-entertainment": + blogs.push( + { + title_fa: "پربازترین بازی‌های ویدیویی سال ۲۰۲۴", + slug: "most-played-video-games-2024", + category: category._id, + description: "مروری بر پربازترین و محبوب‌ترین بازی‌های ویدیویی عرضه شده در سال ۲۰۲۴.", + content: + "سال ۲۰۲۴ با عرضه بازی‌های ویدیویی هیجان‌انگیزی همراه بوده است. از بازی‌های اکشن گرفته تا RPGها، این مقاله به معرفی پربازترین و محبوب‌ترین بازی‌های سال می‌پردازد...", + author: admin!._id, + tags: ["بازی", "ویدیوئی", "مرور"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "نکاتی برای بهبود مهارت‌های بازی‌های رقابتی", + slug: "tips-to-improve-competitive-gaming-skills", + category: category._id, + description: "راهنمایی‌هایی برای ارتقاء مهارت‌ها و موفقیت در بازی‌های رقابتی.", + content: + "بازی‌های رقابتی نیازمند تمرین مداوم و استراتژی‌های هوشمندانه هستند. در این مقاله به بررسی نکاتی می‌پردازیم که می‌تواند به شما کمک کند مهارت‌های بازی رقابتی خود را بهبود ببخشید...", + author: admin!._id, + tags: ["بازی", "رقابتی", "مهارت"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + { + title_fa: "بررسی جدیدترین کنسول‌های بازی", + slug: "review-of-latest-gaming-consoles", + category: category._id, + description: "مروری بر جدیدترین کنسول‌های بازی و ویژگی‌های آن‌ها.", + content: + "کنسول‌های بازی جدید با تکنولوژی‌های پیشرفته‌ای عرضه شده‌اند که تجربه بازی را به سطح بالاتری می‌برند. در این مقاله به بررسی ویژگی‌ها و عملکرد جدیدترین کنسول‌های بازی می‌پردازیم...", + author: admin!._id, + tags: ["کنسول", "بازی", "مرور"], + imageUrl: fakeImageGenerator(), + view: 0, + }, + ); + break; + + default: + console.warn(`No seeder defined for category slug: ${category.slug}`); + break; + } + }); + + // Insert Blogs + await BlogModel.insertMany(blogs); + logger.info("Blogs seeded successfully"); + } catch (error) { + logger.error("Error seeding blogs:", error); + } +}; diff --git a/src/db/seeders/brandSeeders.ts b/src/db/seeders/brandSeeders.ts new file mode 100644 index 0000000..3b2d927 --- /dev/null +++ b/src/db/seeders/brandSeeders.ts @@ -0,0 +1,138 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../common/enums/status.enum"; +import { Logger } from "../../core/logging/logger"; +import { BrandModel } from "../../modules/brand/models/brand.model"; +import { CategoryModel } from "../../modules/category/models/category.model"; + +interface IBrandSeed { + title_fa: string; + title_en: string; + // category: Types.ObjectId; +} + +const brandSeedMap: { [key: string]: IBrandSeed[] } = { + "گوشی موبایل": [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "اپل", title_en: "Apple" }, + { title_fa: "شیائومی", title_en: "Xiaomi" }, + { title_fa: "هواوی", title_en: "Huawei" }, + { title_fa: "نوکیا", title_en: "Nokia" }, + ], + تبلت: [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "اپل", title_en: "Apple" }, + { title_fa: "مایکروسافت", title_en: "Microsoft" }, + { title_fa: "لنوو", title_en: "Lenovo" }, + { title_fa: "هواوی", title_en: "Huawei" }, + ], + "خودکار و خودنویس": [ + { title_fa: "پایلوت", title_en: "Pilot" }, + { title_fa: "شیفر", title_en: "Sheaffer" }, + { title_fa: "پارکر", title_en: "Parker" }, + { title_fa: "لامی", title_en: "Lamy" }, + ], + لامپ: [ + { title_fa: "فیلیپس", title_en: "Philips" }, + { title_fa: "شیائومی", title_en: "Xiaomi" }, + { title_fa: "اپتونیک", title_en: "Optonique" }, + { title_fa: "نورکالا", title_en: "NoorKala" }, + ], + اسپیکر: [ + { title_fa: "جی بی ال", title_en: "JBL" }, + { title_fa: "سونی", title_en: "Sony" }, + ], + + تلویزیون: [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "ال جی", title_en: "LG" }, + { title_fa: "سونی", title_en: "Sony" }, + { title_fa: "پاناسونیک", title_en: "Panasonic" }, + ], + "دوربین عکاسی": [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "ال جی", title_en: "LG" }, + { title_fa: "سونی", title_en: "Sony" }, + { title_fa: "پاناسونیک", title_en: "Nikon" }, + ], + "ساعت هوشمند": [ + { title_fa: "شیائومی", title_en: "Xiaomi" }, + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "اپل", title_en: "Apple" }, + { title_fa: "هواوی", title_en: "Huawei" }, + ], + هدفون: [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "ال جی", title_en: "LG" }, + { title_fa: "سونی", title_en: "Sony" }, + { title_fa: "بوز", title_en: "Bose" }, + ], + "پاور بانک": [ + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "آنکر", title_en: "Anker" }, + ], + "لپ تاپ": [ + { title_fa: "ایسر", title_en: "Acer" }, + { title_fa: "ایسوس", title_en: "Asus" }, + { title_fa: "اپل", title_en: "Apple" }, + { title_fa: "لنوو", title_en: "Lenovo" }, + ], + "لباس زنانه": [ + { title_fa: "زارا", title_en: "Zara" }, + { title_fa: "H&M", title_en: "H&M" }, + { title_fa: "گوچی", title_en: "Gucci" }, + { title_fa: "دیور", title_en: "Dior" }, + ], + "لوازم آشپزخانه": [ + { title_fa: "بوش", title_en: "Bosch" }, + { title_fa: "فیلیپس", title_en: "Philips" }, + { title_fa: "سامسونگ", title_en: "Samsung" }, + { title_fa: "پاناسونیک", title_en: "Panasonic" }, + { title_fa: "ال جی", title_en: "LG" }, + ], + "پلی استیشن": [{ title_fa: "سونی", title_en: "Sony" }], + متفرقه: [{ title_fa: "متفرقه", title_en: "malicious" }], +}; + +export const seedBrands = async (logger: Logger) => { + try { + logger.info("Brands seeding started"); + + const categoryMap: { [key: string]: Types.ObjectId } = {}; + + // Fetch all categories once + const categories = await CategoryModel.find(); + categories.forEach((category) => { + categoryMap[category.title_fa] = category._id; + }); + + const brandDocuments = []; + + // Prepare brand documents in bulk + for (const [categoryTitle, brands] of Object.entries(brandSeedMap)) { + const categoryId = categoryMap[categoryTitle]; + + if (categoryId) { + for (const brand of brands) { + const { title_fa, title_en } = brand; + brandDocuments.push({ + title_fa, + title_en, + category: categoryId, + status: StatusEnum.Approved, + }); + } + } else { + logger.warn(`Category '${categoryTitle}' not found`); + } + } + + // Bulk insert + if (brandDocuments.length > 0) { + await BrandModel.insertMany(brandDocuments); + } + logger.info("Brands seeded successfully"); + } catch (error) { + logger.error("Error seeding brands:", error); + } +}; diff --git a/src/db/seeders/catVariantSeeders.ts b/src/db/seeders/catVariantSeeders.ts new file mode 100644 index 0000000..e984abb --- /dev/null +++ b/src/db/seeders/catVariantSeeders.ts @@ -0,0 +1,98 @@ +import { Logger } from "../../core/logging/logger"; +import { ColorModel } from "../../modules/category/models/color.model"; +import { MeterageModel } from "../../modules/category/models/meterage.model"; +import { SizeModel } from "../../modules/category/models/size.model"; + +const colors = [ + { name: "قرمز", hexColor: "#FF0000" }, + { name: "سبز", hexColor: "#00FF00" }, + { name: "آبی", hexColor: "#0000FF" }, + { name: "مشکی", hexColor: "#000000" }, + { name: "سفید", hexColor: "#FFFFFF" }, + { name: "زرد", hexColor: "#FFFF00" }, + { name: "بنفش", hexColor: "#800080" }, + { name: "نارنجی", hexColor: "#FFA500" }, + { name: "صورتی", hexColor: "#FFC0CB" }, + { name: "خاکستری", hexColor: "#808080" }, +]; + +const sizes = [ + { value: "XS" }, + { value: "S" }, + { value: "M" }, + { value: "L" }, + { value: "XL" }, + { value: "XXL" }, + { value: "3XL" }, + { value: "4XL" }, + { value: "5XL" }, + { value: "6XL" }, +]; + +const meterages = [ + { value: "1" }, + { value: "2" }, + { value: "3" }, + { value: "4" }, + { value: "5" }, + { value: "10" }, + { value: "15" }, + { value: "20" }, + { value: "25" }, + { value: "30" }, +]; + +const seedColors = async () => { + try { + console.log("Seeding colors..."); + await ColorModel.deleteMany({}); + for (const color of colors) { + await ColorModel.create(color); + } + console.log("Colors seeded successfully"); + } catch (error) { + console.error("Error seeding colors:", error); + } +}; + +const seedSizes = async () => { + try { + console.log("Seeding sizes..."); + await SizeModel.deleteMany({}); + + for (const size of sizes) { + await SizeModel.create(size); + } + console.log("Sizes seeded successfully"); + } catch (error) { + console.error("Error seeding sizes:", error); + } +}; + +const seedMeterages = async () => { + try { + console.log("Seeding meterages..."); + await MeterageModel.deleteMany({}); + for (const meterage of meterages) { + await MeterageModel.create(meterage); + } + console.log("Meterages seeded successfully"); + } catch (error) { + console.error("Error seeding meterages:", error); + } +}; + +// Seed function +export const seedCatVariant = async (logger: Logger) => { + try { + logger.info("Categories variant seeding started"); + + await seedColors(); + await seedSizes(); + await seedMeterages(); + + console.log("Seeding complete!"); + } catch (error) { + logger.error("Seeding error:", error); + } +}; diff --git a/src/db/seeders/categorySeeder.ts b/src/db/seeders/categorySeeder.ts new file mode 100644 index 0000000..a7f4b33 --- /dev/null +++ b/src/db/seeders/categorySeeder.ts @@ -0,0 +1,556 @@ +import { faker } from "@faker-js/faker"; +import { Types } from "mongoose"; + +faker.locale = "fa"; +import { CategoryThemeEnum } from "../../common/enums/category.enum"; +import { Logger } from "../../core/logging/logger"; +import { AttributeValueModel } from "../../modules/category/models/attributeValue.model"; +import { CategoryModel } from "../../modules/category/models/category.model"; +import { CategoryAttributeModel } from "../../modules/category/models/CategoryAttribute.model"; +import { ColorModel } from "../../modules/category/models/color.model"; +import { MeterageModel } from "../../modules/category/models/meterage.model"; +import { SizeModel } from "../../modules/category/models/size.model"; + +interface ICategorySeed { + title_fa: string; + title_en: string; + icon: string; + imageUrl: string; + description: string; + leaf: boolean; + theme?: CategoryThemeEnum; + parent?: Types.ObjectId; +} + +interface ICategoryAttributeSeed { + title: string; + hint?: string; + type: string; + multiple: boolean; + required: boolean; +} + +const rootCategories: ICategorySeed[] = [ + { + title_fa: "موبایل و تبلت", + icon: "mobile", + imageUrl: faker.image.imageUrl(), + description: "لوازم جانبی", + title_en: "mobile", + leaf: false, + }, + { + title_fa: "لوازم تحریر و اداری", + icon: "stationery", + description: "انواع لوازم تحریر و اداری", + title_en: "stationery-office", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "برق و الکتریکی", + icon: "electric", + description: "لوازم برق و الکتریکی", + title_en: "electric-electrical", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "لوازم و ابزارآلات", + icon: "tools", + description: "انواع لوازم و ابزارآلات", + title_en: "tools-equipment", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "صوت و تصویر", + icon: "audio-visual", + description: "انواع لوازم صوتی و تصویری", + title_en: "audio-visual", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "کامپیوتر", + icon: "computer", + description: "انواع کامپیوتر و لوازم جانبی", + title_en: "computer", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "کنسول بازی", + icon: "console", + description: "انواع کنسول بازی و لوازم جانبی", + title_en: "game-console", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "ورزش و سفر", + icon: "sports", + description: "لوازم ورزشی و سفر", + title_en: "sports-travel", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { title_fa: "پوشاک و مد", icon: "fashion", description: "لباس و مد", title_en: "fashion", leaf: false, imageUrl: faker.image.imageUrl() }, + { + title_fa: "لوازم خانگی", + icon: "home-appliances", + description: "لوازم خانه", + title_en: "home-appliances", + leaf: false, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "متفرقه", + icon: "malicious", + description: "متفرقه", + title_en: "malicious", + leaf: true, + imageUrl: faker.image.imageUrl(), + }, +]; + +const subcategories: { [key: string]: ICategorySeed[] } = { + "موبایل و تبلت": [ + { + title_fa: "گوشی موبایل", + icon: "phone", + description: "انواع گوشی های موبایل", + title_en: "mobile-phones", + leaf: true, + imageUrl: faker.image.imageUrl(), + theme: CategoryThemeEnum.Colored, + }, + { + title_fa: "تبلت", + icon: "tablet", + description: "انواع تبلت ها", + title_en: "tablets", + leaf: true, + theme: CategoryThemeEnum.Colored, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "ساعت هوشمند", + icon: "watch", + description: "انواع ساعت هوشمند", + title_en: "smart-watch", + leaf: true, + theme: CategoryThemeEnum.Colored, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "پاور بانک", + icon: "watch", + description: "انواع پاور بانک", + title_en: "power-banks", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "لوازم تحریر و اداری": [ + { + title_fa: "خودکار و خودنویس", + icon: "pen", + description: "انواع خودکار و خودنویس", + title_en: "pens", + leaf: true, + theme: CategoryThemeEnum.Colored, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "دفتر و کاغذ", + icon: "paper", + description: "انواع دفتر و کاغذ", + title_en: "paper-notebooks", + leaf: true, + theme: CategoryThemeEnum.Colored, + imageUrl: faker.image.imageUrl(), + }, + ], + "برق و الکتریکی": [ + { + title_fa: "لامپ", + icon: "bulb", + description: "انواع لامپ ها", + title_en: "bulbs", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "کابل و سیم", + icon: "cable", + description: "انواع کابل و سیم", + title_en: "cables-wires", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "لوازم و ابزارآلات": [ + { + title_fa: "آچار و پیچ گوشتی", + icon: "wrench", + description: "انواع آچار و پیچ گوشتی", + title_en: "wrenches-screwdrivers", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "دریل", + icon: "drill", + description: "انواع دریل ها", + title_en: "drills", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "صوت و تصویر": [ + { + title_fa: "تلویزیون", + icon: "tv", + description: "انواع تلویزیون", + title_en: "televisions", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "اسپیکر", + icon: "speaker", + description: "انواع اسپیکر", + title_en: "speakers", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "هدفون", + icon: "headphone", + description: "انواع هدفون", + title_en: "headphones", + leaf: true, + theme: CategoryThemeEnum.Colored, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "دوربین عکاسی", + icon: "camera", + description: "انواع دوربین عکاسی", + title_en: "cameras", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + کامپیوتر: [ + { + title_fa: "لپ تاپ", + icon: "laptop", + description: "انواع لپ تاپ", + title_en: "laptops", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "موس و کیبورد", + icon: "mouse-keyboard", + description: "انواع موس و کیبورد", + title_en: "mouse-keyboards", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "کنسول بازی": [ + { + title_fa: "پلی استیشن", + icon: "playstation", + description: "انواع پلی استیشن", + title_en: "playstations", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "ایکس باکس", + icon: "xbox", + description: "انواع ایکس باکس", + title_en: "xbox", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "ورزش و سفر": [ + { + title_fa: "تجهیزات ورزشی", + icon: "sports-equipment", + description: "انواع تجهیزات ورزشی", + title_en: "sports-equipment", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "لوازم سفر", + icon: "travel", + description: "انواع لوازم سفر", + title_en: "travel-equipment", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "پوشاک و مد": [ + { + title_fa: "لباس زنانه", + icon: "women-clothing", + description: "انواع لباس زنانه", + title_en: "women-clothing", + leaf: true, + theme: CategoryThemeEnum.Sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "لباس مردانه", + icon: "men-clothing", + description: "انواع لباس مردانه", + title_en: "men-clothing", + leaf: true, + theme: CategoryThemeEnum.Sized, + imageUrl: faker.image.imageUrl(), + }, + ], + "لوازم خانگی": [ + { + title_fa: "لوازم آشپزخانه", + icon: "kitchen-appliances", + description: "انواع لوازم آشپزخانه", + title_en: "kitchen-appliances", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + { + title_fa: "لوازم نظافت", + icon: "cleaning-appliances", + description: "انواع لوازم نظافت", + title_en: "cleaning-appliances", + leaf: true, + theme: CategoryThemeEnum.No_color_No_sized, + imageUrl: faker.image.imageUrl(), + }, + ], +}; + +const categoryAttributesMap: { [key: string]: ICategoryAttributeSeed[] } = { + "گوشی موبایل": [ + { title: "باتری", hint: "ظرفیت باتری", type: "select", multiple: false, required: true }, + { title: "دوربین", hint: "کیفیت دوربین", type: "select", multiple: false, required: true }, + { title: "سیستم عامل", hint: "نوع سیستم عامل", type: "select", multiple: false, required: true }, + { title: "حافظه داخلی موبایل", hint: "ظرفیت حافظه", type: "select", multiple: false, required: true }, + { title: "پشتیبانی از 5G", hint: "آیا پشتیبانی می‌کند؟", type: "checkbox", multiple: false, required: false }, + ], + تبلت: [ + { title: "اندازه صفحه تبلت", hint: "اینچ", type: "select", multiple: false, required: true }, + { title: "پردازنده تبلت", hint: "نوع پردازنده", type: "select", multiple: false, required: true }, + { title: "دوربین جلو", hint: "کیفیت دوربین جلو", type: "select", multiple: false, required: false }, + { title: "ظرفیت باتری", hint: "میلی‌آمپر ساعت", type: "select", multiple: false, required: true }, + { title: "اتصال به شبکه", hint: "نوع شبکه", type: "select", multiple: false, required: false }, + ], + "خودکار و خودنویس": [ + { title: "نوع جوهر", hint: "جوهر مورد استفاده", type: "select", multiple: false, required: true }, + { title: "ضخامت نوشتاری", hint: "ضخامت نوک", type: "select", multiple: false, required: true }, + { title: "قابلیت شارژ مجدد", hint: "آیا قابل شارژ است؟", type: "checkbox", multiple: false, required: false }, + { title: "جنس بدنه خودکار", hint: "جنس بدنه خودکار", type: "select", multiple: false, required: true }, + { title: "رنگ جوهر", hint: "رنگ جوهر خودکار", type: "select", multiple: false, required: true }, + ], + لامپ: [ + { title: "نوع لامپ", hint: "نوع لامپ (LED، هالوژن و غیره)", type: "select", multiple: false, required: true }, + { title: "توان مصرفی لامپ", hint: "وات", type: "select", multiple: false, required: true }, + { title: "رنگ نور", hint: "رنگ نور", type: "select", multiple: false, required: true }, + { title: "طول عمر", hint: "ساعت", type: "select", multiple: false, required: true }, + { title: "قابلیت تنظیم شدت نور", hint: "آیا قابل تنظیم است؟", type: "checkbox", multiple: false, required: false }, + ], + تلویزیون: [ + { title: "اندازه صفحه تلویزیون", hint: "اینچ", type: "select", multiple: false, required: true }, + { title: "رزولوشن", hint: "کیفیت تصویر", type: "select", multiple: false, required: true }, + { title: "نوع صفحه", hint: "LED, OLED, QLED", type: "select", multiple: false, required: true }, + { title: "هوشمند", hint: "آیا هوشمند است؟", type: "checkbox", multiple: false, required: false }, + { title: "درگاه‌های ارتباطی", hint: "نوع و تعداد درگاه‌ها", type: "select", multiple: true, required: true }, + ], + "لپ تاپ": [ + { title: "پردازنده لپ تاپ", hint: "نوع پردازنده", type: "select", multiple: false, required: true }, + { title: "حافظه رم", hint: "ظرفیت حافظه رم", type: "select", multiple: false, required: true }, + { title: "حافظه داخلی", hint: "ظرفیت حافظه", type: "select", multiple: false, required: true }, + { title: "اندازه صفحه", hint: "اینچ", type: "select", multiple: false, required: true }, + { title: "کارت گرافیک", hint: "نوع کارت گرافیک", type: "select", multiple: false, required: true }, + ], + "لباس زنانه": [ + { title: "جنس پارچه", hint: "نوع پارچه", type: "select", multiple: false, required: true }, + { title: "نوع یقه", hint: "یقه لباس", type: "select", multiple: false, required: true }, + { title: "نوع آستین", hint: "آستین لباس", type: "select", multiple: false, required: true }, + { title: "مدل لباس", hint: "نوع و مدل لباس", type: "select", multiple: false, required: true }, + { title: "نوع شستشو", hint: "نحوه شستشو", type: "select", multiple: false, required: false }, + ], + "لوازم آشپزخانه": [ + { title: "توان مصرفی", hint: "توان لوازم برقی", type: "select", multiple: false, required: true }, + { title: "جنس بدنه", hint: "جنس بدنه دستگاه", type: "select", multiple: false, required: true }, + { title: "ظرفیت", hint: "ظرفیت دستگاه", type: "select", multiple: false, required: true }, + { title: "قابلیت تنظیم", hint: "آیا قابلیت تنظیم دارد؟", type: "checkbox", multiple: false, required: false }, + { title: "نوع کاربری", hint: "کاربری دستگاه", type: "select", multiple: false, required: true }, + ], + // Add more categories as needed +}; + +const attributeValuesMap: { [key: string]: string[] } = { + باتری: ["3000mAh", "4000mAh", "5000mAh", "6000mAh", "7000mAh"], + دوربین: ["12MP", "16MP", "20MP", "48MP", "108MP"], + "سیستم عامل": ["اندروید", "iOS", "ویندوز", "لینوکس", "سایر"], + "حافظه داخلی موبایل": ["32GB", "64GB", "128GB", "256GB", "512GB"], + "پشتیبانی از 5G": ["بله", "خیر"], + "اندازه صفحه تبلت": ["7 اینچ", "10 اینچ", "13 اینچ", "15 اینچ", "17 اینچ"], + "پردازنده تبلت": ["Intel", "AMD", "Apple M1", "Qualcomm", "MediaTek"], + "دوربین جلو": ["5MP", "8MP", "12MP", "16MP", "20MP"], + "ظرفیت باتری": ["2000mAh", "3000mAh", "4000mAh", "5000mAh", "6000mAh"], + "اتصال به شبکه": ["WiFi", "4G", "5G", "Ethernet", "بلوتوث"], + "نوع جوهر": ["ژل", "روغنی", "آبی", "خودکار", "پلاستیکی"], + "ضخامت نوشتاری": ["0.5mm", "0.7mm", "1.0mm", "1.2mm", "1.5mm"], + "قابلیت شارژ مجدد": ["بله", "خیر"], + "جنس بدنه خودکار": ["فلز", "پلاستیک", "چوب", "سرامیک", "کربن"], + "رنگ جوهر": ["مشکی", "آبی", "قرمز", "سبز", "بنفش"], + "نوع لامپ": ["LED", "هالوژن", "فلورسنت", "رشته‌ای", "پلاسما"], + "توان مصرفی لامپ": ["5W", "10W", "15W", "20W", "25W"], + "رنگ نور": ["سفید", "زرد", "آبی", "سبز", "قرمز"], + "طول عمر": ["1000 ساعت", "5000 ساعت", "10000 ساعت", "20000 ساعت", "50000 ساعت"], + "قابلیت تنظیم شدت نور": ["بله", "خیر"], + "اندازه صفحه تلویزیون": ["20 اینچ", "32 اینچ", "40 اینچ", "60 اینچ", "70 اینچ"], + رزولوشن: ["720p", "1080p", "4K", "8K", "HDR"], + "نوع صفحه": ["LED", "OLED", "QLED", "Plasma", "LCD"], + هوشمند: ["بله", "خیر"], + "درگاه‌های ارتباطی": ["HDMI", "USB", "Bluetooth", "WiFi", "Ethernet"], + "پردازنده لپ تاپ": ["Intel", "AMD", "Apple M1", "Qualcomm", "Apple M2"], + "حافظه رم": ["4GB", "8GB", "16GB", "32GB", "64GB"], + "حافظه داخلی": ["128GB", "256GB", "512GB", "1TB"], + "اندازه صفحه": ["14 اینچ", "12 اینچ", "16 اینچ"], + "کارت گرافیک": ["NVIDIA", "AMD", "Intel", "Apple M1", "ARM Mali"], + "جنس پارچه": ["پنبه", "پلی‌استر", "نایلون", "ریون", "کتان"], + "نوع یقه": ["گرد", "هفت", "ایستاده", "دکمه‌ای", "یقه اسکی"], + "نوع آستین": ["کوتاه", "بلند", "سه‌ربع", "بی‌آستین", "کشباف"], + "مدل لباس": ["تی‌شرت", "پیراهن", "ژاکت", "هودی", "پلیور"], + "نوع شستشو": ["ماشینی", "دستی", "خشک‌شویی", "آب سرد", "آب گرم"], + "توان مصرفی": ["150W", "200W", "400W"], + "جنس بدنه": ["فلز", "پلاستیک", "چوب", "سرامیک", "کربن"], + ظرفیت: ["1 لیتر", "2 لیتر", "3 لیتر", "4 لیتر", "5 لیتر"], + "قابلیت تنظیم": ["بله", "خیر"], + "نوع کاربری": ["آبمیوه‌گیری", "مخلوط‌کن", "آسیاب", "کباب‌پز", "زودپز"], + // Add more attributes and their values as needed +}; + +export const seedCategories = async (logger: Logger) => { + try { + logger.info("Categories seeding started"); + + const colors = (await ColorModel.find()).map((color) => color._id); + const sizes = (await SizeModel.find()).map((size) => size._id); + const meterages = (await MeterageModel.find()).map((meterage) => meterage._id); + + const createdCategories: { [key: string]: Types.ObjectId } = {}; + const createdSubCategories: { [key: string]: Types.ObjectId } = {}; + + // Create root categories + for (const category of rootCategories) { + const { title_fa, icon, description, title_en, leaf, imageUrl } = category; + + const rootCategory = new CategoryModel({ title_fa, icon, title_en, description, leaf, imageUrl }); + await rootCategory.save(); + + createdCategories[title_fa] = rootCategory._id; + } + + // Create subcategories + for (const [rootName, subs] of Object.entries(subcategories)) { + const parentId = createdCategories[rootName]; + if (parentId) { + for (const subcategory of subs) { + const { title_fa, icon, description, title_en, leaf, theme, imageUrl } = subcategory; + + const catVariants = + theme === CategoryThemeEnum.Colored + ? [...colors] + : theme === CategoryThemeEnum.Meterage + ? [...meterages] + : theme === CategoryThemeEnum.Sized + ? [...sizes] + : []; + + const subCategoryDoc = new CategoryModel({ + title_fa, + icon, + imageUrl, + title_en, + description, + leaf, + theme, + variants: catVariants, + parent: parentId, + }); + await subCategoryDoc.save(); + + // Save the subcategory ID for later use + createdSubCategories[title_fa] = subCategoryDoc._id; + } + } + } + + const createdAttributes: { [key: string]: number } = {}; + + // Generate attributes for each subcategory + for (const [subcategoryTitle, attributes] of Object.entries(categoryAttributesMap)) { + const subcategoryId = createdSubCategories[subcategoryTitle]; + if (subcategoryId) { + for (const attr of attributes) { + const attributeDoc = new CategoryAttributeModel({ + category: subcategoryId, + title: attr.title, + hint: attr.hint, + type: attr.type, + multiple: attr.multiple, + required: attr.required, + }); + await attributeDoc.save(); + createdAttributes[attr.title] = attributeDoc._id; + } + } else { + logger.warn(`Subcategory '${subcategoryTitle}' not found.`); + } + } + // Generate values for each attribute + for (const [attributeTitle, attributeId] of Object.entries(createdAttributes)) { + const values = attributeValuesMap[attributeTitle] || []; + + for (const valueText of values) { + await AttributeValueModel.create({ + attribute: attributeId, + text: valueText, + }); + + // await valueDoc.save(); + } + } + + logger.info("Categories, subcategories, attributes, and values seeded successfully"); + } catch (error) { + logger.error("Error seeding categories:", error); + } +}; diff --git a/src/db/seeders/contractSeeder.ts b/src/db/seeders/contractSeeder.ts new file mode 100644 index 0000000..a74e3f2 --- /dev/null +++ b/src/db/seeders/contractSeeder.ts @@ -0,0 +1,17 @@ +import { Logger } from "../../core/logging/logger"; +import { ContractModel } from "../../modules/admin/models/contract.model"; + +export const contractSeeder = async (logger: Logger) => { + try { + const existContract = await ContractModel.exists({}); + if (existContract) return; + + await ContractModel.create({ + content: "متن قرار داد را اینجا قرار دهید", + }); + + logger.info("Contract collection seeded!"); + } catch (error) { + logger.error("Contract collection seed failed!", error); + } +}; diff --git a/src/db/seeders/defaultAdmin.ts b/src/db/seeders/defaultAdmin.ts new file mode 100644 index 0000000..e284197 --- /dev/null +++ b/src/db/seeders/defaultAdmin.ts @@ -0,0 +1,68 @@ +import { Logger } from "../../core/logging/logger"; +import { PermissionEnum } from "../../modules/admin/models/Abstraction/IPermission"; +import { RoleEnum } from "../../modules/admin/models/Abstraction/IRole"; +import { AdminModel } from "../../modules/admin/models/admin.model"; +import { PermissionModel } from "../../modules/admin/models/permission.model"; +import { RoleModel } from "../../modules/admin/models/role.model"; +import { ShipmentModel } from "../../modules/shipment/models/shipment.model"; +import { OwnerRef } from "../../modules/shop/models/Abstraction/IShop"; +import { ShopModel } from "../../modules/shop/models/shop.model"; + +const createDefaultAdmin = async (logger: Logger) => { + logger.info("createDefaultAdmin started", "SEED"); + const email = process.env.DEFAULT_ADMIN_EMAIL; + const password = process.env.DEFAULT_ADMIN_PASSWORD; + + if (!email || !password) { + throw new Error("Default admin credentials are not set in the environment variables"); + } + // Create permissions based on the enum + const permissions = Object.values(PermissionEnum); + + const createdPermissions = await Promise.all( + permissions.map(async (perm) => { + const existingPermission = await PermissionModel.findOne({ name: perm }); + if (!existingPermission) { + return PermissionModel.create({ name: perm }); + } + return existingPermission; + }), + ); + + // create or find the SUPERADMIN role + let superAdminRole = await RoleModel.findOne({ name: RoleEnum.SUPERADMIN }); + if (!superAdminRole) { + superAdminRole = await RoleModel.create({ + name: RoleEnum.SUPERADMIN, + }); + logger.info("SUPERADMIN role created", "SEED"); + } else { + logger.warn("SUPERADMIN role already exists", "SEED"); + } + + let admin = await AdminModel.findOne({ email }); + if (!admin) { + admin = await AdminModel.create({ + email, + password, + fullName: "defaultAdmin", + userName: "defaultAdmin", + permissions: createdPermissions.map((perm) => perm._id), + role: superAdminRole._id, + }); + const shipments = (await ShipmentModel.find()).map((shipper) => shipper._id); + + await ShopModel.create({ + owner: admin._id.toString(), + ownerRef: OwnerRef.ADMIN, + shipmentMethod: shipments, + shopDescription: "فروشگاه شینان", + shopName: "شینان", + }); + logger.info("Default admin user and native shop created", "SEED"); + } else { + logger.warn("Default admin user and native shop already exists", "SEED"); + } +}; + +export { createDefaultAdmin }; diff --git a/src/db/seeders/faqSeeder.ts b/src/db/seeders/faqSeeder.ts new file mode 100644 index 0000000..94f9927 --- /dev/null +++ b/src/db/seeders/faqSeeder.ts @@ -0,0 +1,36 @@ +import { Logger } from "../../core/logging/logger"; +import { FaqModel } from "../../modules/faq/models/faq.model"; + +export const seedFaqs = async (logger: Logger) => { + try { + logger.info("start seeding the FAQ"); + const faqs = [ + { + question: "چگونه میتوانم سفارش خود را پیگیری کنم؟", + answer: "برای پیگیری سفارش خود، می‌توانید به حساب کاربری خود وارد شوید و از قسمت سفارشات من، وضعیت سفارش خود را مشاهده کنید.", + }, + { + question: "آیا امکان بازگشت کالا وجود دارد؟", + answer: "بله، در صورت داشتن شرایط بازگشت، می‌توانید کالا را تا ۷ روز پس از تحویل بازگردانید.", + }, + { + question: "چگونه میتوانم حساب کاربری ایجاد کنم؟", + answer: "برای ایجاد حساب کاربری، کافیست در صفحه اصلی سایت به قسمت ثبت نام مراجعه کرده و اطلاعات خود را وارد کنید.", + }, + { + question: "روش‌های پرداخت چگونه است؟", + answer: "پرداخت‌ها می‌تواند از طریق کارت‌های شتاب، درگاه‌های بانکی و پرداخت در محل انجام شود.", + }, + { + question: "چگونه میتوانم از کد تخفیف استفاده کنم؟", + answer: "هنگام پرداخت، در قسمت کد تخفیف، کد خود را وارد کنید و سپس دکمه اعمال را بزنید.", + }, + ]; + + await FaqModel.insertMany(faqs); + + logger.info("FAQ data seeded successfully"); + } catch (error) { + logger.error("Error seeding FAQ data:", error); + } +}; diff --git a/src/db/seeders/index.ts b/src/db/seeders/index.ts new file mode 100644 index 0000000..5e9e878 --- /dev/null +++ b/src/db/seeders/index.ts @@ -0,0 +1,62 @@ +import "reflect-metadata"; +import { config } from "dotenv"; +config(); +import mongoose from "mongoose"; + +import { Logger } from "../../core/logging/logger"; +import { connectMongo } from "../connection"; +// import { backfillHierarchy } from "./backfillHierarchy"; +import { blogCategorySeeder } from "./blogCategorySeeder"; +import { blogSeeder } from "./blogSeeder"; +import { contractSeeder } from "./contractSeeder"; +// import { seedMedia } from "./mediaSeeder"; +import { seedCityAndProvince } from "./iranCityseeder"; +import { SeedPricing } from "./pricingSeeder"; +import { clearDatabase } from "../clearDatabase"; +import { backfillHierarchy } from "./backfillHierarchy"; +import { createDefaultAdmin } from "./defaultAdmin"; +import { seedFaqs } from "./faqSeeder"; +import { seedMedia } from "./mediaSeeder"; +import { seedPaymentMethods } from "./pgSeeder"; +import { ReportQuesSeeder } from "./reportQuestion"; +import { createDefaultRoles } from "./roleSeeder"; +import { SeedShipment } from "./shipmentSeeder"; +import { seedTicketCategories } from "./ticketSeeder"; +import { seedWarranty } from "./warrantySeeders"; + +const logger = new Logger("Seeder"); + +const seedDatabase = async () => { + try { + await connectMongo(); + logger.info("connected to mongodb"); + await clearDatabase(logger); + await backfillHierarchy(); + await blogCategorySeeder(logger); + await blogSeeder(logger); + await contractSeeder(logger); + await createDefaultAdmin(logger); + await seedFaqs(logger); + await seedCityAndProvince(logger); + await SeedPricing(logger); + await seedMedia(logger); + await seedPaymentMethods(logger); + await ReportQuesSeeder(logger); + await createDefaultRoles(logger); + await SeedShipment(logger); + await seedTicketCategories(logger); + await seedWarranty(logger); + logger.info("Seeding completed"); + process.exit(0); + } catch (error) { + // + console.error("Error during seeding:", error); + logger.error("Error during seeding:", error); + process.exit(1); + } finally { + // + await mongoose.connection.close(); + } +}; + +seedDatabase(); diff --git a/src/db/seeders/iranCityseeder.ts b/src/db/seeders/iranCityseeder.ts new file mode 100644 index 0000000..66f472e --- /dev/null +++ b/src/db/seeders/iranCityseeder.ts @@ -0,0 +1,39 @@ +import { Logger } from "../../core/logging/logger"; +import { CityModel } from "../../modules/address/models/city.model"; +import { ProvinceModel } from "../../modules/address/models/province.model"; +import province from "../data/json/ostan.json"; +import city from "../data/json/shahr.json"; + +interface IProvince { + id: number; + name: string; +} +interface ICities extends IProvince { + ostan: number; +} + +const provinces: IProvince[] = province; +const cities: ICities[] = city; + +export const seedCityAndProvince = async (logger: Logger) => { + try { + logger.info("start seeding the city and province", "SEED"); + + for (const province of provinces) { + await ProvinceModel.create({ + name: province.name, + }); + } + + for (const city of cities) { + await CityModel.create({ + name: city.name, + province: city.ostan, + }); + } + + logger.info("city and province seeding completed", "SEED"); + } catch (error) { + logger.error("error in seeding the city and province", error); + } +}; diff --git a/src/db/seeders/jobSeeder.ts b/src/db/seeders/jobSeeder.ts new file mode 100644 index 0000000..597ca69 --- /dev/null +++ b/src/db/seeders/jobSeeder.ts @@ -0,0 +1,46 @@ +import { Logger } from "../../core/logging/logger"; +import { JobType } from "../../modules/job/models/Abstraction/IJob"; +import { JobModel } from "../../modules/job/models/job.model"; + +export const seedJobs = async (logger: Logger) => { + const jobs = [ + { + title: "برنامه‌نویس وب", + description: "ما به دنبال یک برنامه‌نویس وب با تجربه در React و Node.js هستیم.", + location: "تهران", + type: JobType.FullTime, + }, + { + title: "طراح UI/UX", + description: "نیازمند یک طراح UI/UX با تجربه برای بهبود تجربه کاربری وب‌سایت هستیم.", + location: "اصفهان", + type: JobType.Contract, + }, + { + title: "کارشناس فروش", + description: "به یک کارشناس فروش برای توسعه بازار و جذب مشتری نیازمندیم.", + location: "شیراز", + type: JobType.PartTime, + }, + { + title: "مدیر پروژه", + description: "نیازمند مدیر پروژه‌ای با تجربه در مدیریت تیم‌های فنی برای مدیریت پروژه‌های IT.", + location: "مشهد", + type: JobType.FullTime, + }, + { + title: "تحلیل‌گر داده", + description: "به یک تحلیل‌گر داده برای تحلیل و بررسی داده‌های شرکت نیاز داریم.", + location: "تبریز", + type: JobType.Contract, + }, + ]; + + try { + logger.info("start seeding the jobs"); + await JobModel.insertMany(jobs); + logger.info("Job seeding completed!"); + } catch (error) { + logger.error("Job seeding failed: ", error); + } +}; diff --git a/src/db/seeders/mediaSeeder.ts b/src/db/seeders/mediaSeeder.ts new file mode 100644 index 0000000..6482bc4 --- /dev/null +++ b/src/db/seeders/mediaSeeder.ts @@ -0,0 +1,193 @@ +import { Logger } from "../../core/logging/logger"; +import { BannerModel } from "../../modules/admin/models/media.model"; + +export async function seedMedia(logger: Logger) { + try { + // await SliderModel.deleteMany({}); + // await BannerModel.deleteMany({}); + // logger.info("Cleared existing data"); + + // // Seed Sliders + // const sliders = [ + // { + // displayLocation: DisplayLocations.Mobile, + // imageUrl: "https://loremflickr.com/1920/1080", + // altText: "اسلایدر ۱", + // title: "خوش آمدید به اسلایدر ۱", + // linkUrl: "/slider1", + // isActive: true, + // }, + // { + // displayLocation: DisplayLocations.Mobile, + // imageUrl: "https://loremflickr.com/1920/1080", + // altText: "اسلایدر ۲", + // title: "اسلایدر ۲ را کشف کنید", + // linkUrl: "/slider2", + // isActive: true, + // }, + // { + // displayLocation: DisplayLocations.Desktop, + // imageUrl: "https://loremflickr.com/1920/1080", + // altText: "اسلایدر ۳", + // title: "بیشتر درباره اسلایدر ۳ بدانید", + // linkUrl: "/slider3", + // isActive: true, + // }, + // { + // displayLocation: DisplayLocations.Desktop, + // imageUrl: "https://loremflickr.com/1920/1080", + // altText: "اسلایدر ۴", + // title: "اسلایدر جدید و جذاب", + // linkUrl: "/slider4", + // isActive: true, + // }, + // ]; + + // for (const slider of sliders) { + // await SliderModel.create(slider); + // } + + // logger.info(" Sliders Seeded"); + + const existingBanners = await BannerModel.countDocuments({}); + if (existingBanners && existingBanners == 16) return; + + await BannerModel.deleteMany({}); + + const banners = [ + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر ۱", + title: "خوش آمدید به صفحه اصلی", + linkUrl: "/banner1", + isActive: true, + order: 1, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر ۲", + title: "درباره ما بیشتر بدانید", + linkUrl: "/banner2", + isActive: true, + order: 2, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر ۳", + title: "تماس با ما", + linkUrl: "/banner3", + isActive: true, + order: 3, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر ۴", + title: "خدمات ما را کشف کنید", + linkUrl: "/banner4", + isActive: true, + order: 4, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر ۵", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner5", + isActive: true, + order: 5, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 6", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner6", + isActive: true, + order: 6, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 7", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner7", + isActive: true, + order: 7, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 8", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner8", + isActive: true, + order: 8, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 9", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner9", + isActive: true, + order: 9, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 10", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner10", + isActive: true, + order: 10, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 11", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner11", + isActive: true, + order: 11, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 12", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner12", + isActive: true, + order: 12, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 13", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner13", + isActive: true, + order: 13, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 14", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner14", + isActive: true, + order: 14, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 15", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner15", + isActive: true, + order: 15, + }, + { + imageUrl: "https://storage.shinan.ir/banner-placeholder.png", + altText: "بنر 16", + title: "محصولات ما را مشاهده کنید", + linkUrl: "/banner16", + isActive: true, + order: 16, + }, + ]; + + await BannerModel.insertMany(banners); + logger.info("Banners seeding completed!"); + } catch (error) { + logger.error("Error seeding the Medias: ", error); + } +} diff --git a/src/db/seeders/pgSeeder.ts b/src/db/seeders/pgSeeder.ts new file mode 100644 index 0000000..e82f624 --- /dev/null +++ b/src/db/seeders/pgSeeder.ts @@ -0,0 +1,50 @@ +import { PaymentMethodType } from "../../common/enums/payment.enum"; +import { Logger } from "../../core/logging/logger"; +import { PaymentMethodModel } from "../../modules/payment/models/paymentMethod.model"; + +// Define payment method data +const paymentMethods = [ + { + title_fa: "پرداخت اینترنتی", + title_en: "Internet Payment Gateway (IPG)", + description: "پرداخت آنلاین با تمامی کارت‌های بانکی", + provider: "Zarinpal", + isActive: true, + type: PaymentMethodType.Online, + }, + { + title_fa: "پرداخت اینترنتی", + title_en: "Internet Payment Gateway (IPG)", + description: "پرداخت آنلاین با تمامی کارت‌های بانکی", + provider: "asanPardakht", + isActive: false, + type: PaymentMethodType.Online, + }, + { + title_fa: "پرداخت در محل (با کارت بانکی)", + title_en: "POS", + description: "هنگام تحویل از طریق کارت‌های بانکی", + isActive: false, + type: PaymentMethodType.POS, + }, + { + title_fa: "کیف پول", + title_en: "Wallet", + isActive: false, + type: PaymentMethodType.Wallet, + }, + // Add more payment methods as necessary +]; + +// Seeder function to insert the payment methods +export const seedPaymentMethods = async (logger: Logger) => { + try { + logger.info("PaymentMethods seeding started"); + + await PaymentMethodModel.insertMany(paymentMethods); + + logger.info("Payment methods seeded successfully"); + } catch (error) { + logger.error("Error seeding payment methods:", error); + } +}; diff --git a/src/db/seeders/pricingSeeder.ts b/src/db/seeders/pricingSeeder.ts new file mode 100644 index 0000000..e721b62 --- /dev/null +++ b/src/db/seeders/pricingSeeder.ts @@ -0,0 +1,27 @@ +import { Logger } from "../../core/logging/logger"; +import { IPricing, PricingTypeEnum } from "../../modules/pricing/models/Abstraction/IPricing"; +import { PricingModel } from "../../modules/pricing/models/pricing.model"; + +export const SeedPricing = async (logger: Logger) => { + const pricing: IPricing[] = [ + { type: PricingTypeEnum.INSERT_FEE, price: 100_000 }, + { type: PricingTypeEnum.PHOTOGRAPHY_FEE, price: 150_000 }, + { type: PricingTypeEnum.UNBOXING_VIDEO_FEE, price: 200_000 }, + { type: PricingTypeEnum.EXPERT_REVIEWS_FEE, price: 200_000 }, + ]; + + try { + logger.info("Pricing seeding started"); + + const existPricing = await PricingModel.countDocuments({}); + if (existPricing && existPricing == 4) return; + + await PricingModel.deleteMany({}); + + await PricingModel.insertMany(pricing); + + logger.info("Pricing seeded successfully"); + } catch (error) { + logger.error("Failed to Pricing:", error); + } +}; diff --git a/src/db/seeders/productSeeders.ts b/src/db/seeders/productSeeders.ts new file mode 100644 index 0000000..ff34864 --- /dev/null +++ b/src/db/seeders/productSeeders.ts @@ -0,0 +1,325 @@ +import { faker } from "@faker-js/faker"; + +faker.locale = "fa"; +import { ProductMarketStatus } from "../../common/enums/product.enum"; +import { Logger } from "../../core/logging/logger"; +import { BrandModel } from "../../modules/brand/models/brand.model"; +import { AttributeValueModel } from "../../modules/category/models/attributeValue.model"; +import { CategoryModel } from "../../modules/category/models/category.model"; +import { CategoryAttributeModel } from "../../modules/category/models/CategoryAttribute.model"; +import { ColorModel } from "../../modules/category/models/color.model"; +import { MeterageModel } from "../../modules/category/models/meterage.model"; +import { SizeModel } from "../../modules/category/models/size.model"; +import { ISpecifications } from "../../modules/product/models/Abstraction/IProduct"; +import { ProductModel } from "../../modules/product/models/product.model"; +import { ProductVariantModel } from "../../modules/product/models/productVariant.model"; +import { SellerModel } from "../../modules/seller/models/seller.model"; +import { ShipmentModel } from "../../modules/shipment/models/shipment.model"; +import { ShopModel } from "../../modules/shop/models/shop.model"; +import { WalletModel } from "../../modules/wallet/models/wallet.model"; +import { WarrantyModel } from "../../modules/warranty/models/warranty.model"; +import { TimeService } from "../../utils/time.service"; +import { CreateFakeSellerData, createFakeShopData, fakeProductsData } from "../data/product"; + +type ProductCategory = + | "mobile" + | "stationery" + | "electric-electrical" + | "tools-equipment" + | "audio-visual" + | "computer" + | "game-console" + | "sports-travel" + | "fashion" + | "home-appliances" + | "mobile-phones" + | "tablets" + | "pens" + | "paper-notebooks" + | "bulbs" + | "cables-wires" + | "wrenches-screwdrivers" + | "drills" + | "televisions" + | "speakers" + | "laptops" + | "mouse-keyboards" + | "playstations" + | "xbox" + | "sports-equipment" + | "cameras" + | "headphones" + | "travel-equipment" + | "women-clothing" + | "men-clothing" + | "kitchen-appliances" + | "smart-watch" + | "power-banks" + | "cleaning-appliances" + | "malicious"; + +type ProductBrand = + | "Samsung" + | "LG" + | "Bosch" + | "Apple" + | "Asus" + | "Xiaomi" + | "Sony" + | "JBL" + | "Bose" + | "Huawei" + | "Nokia" + | "Nikon" + | "Panasonic" + | "Anker" + | "Microsoft" + | "Lenovo" + | "Pilot" + | "Sheaffer" + | "Parker" + | "Lamy" + | "Parker" + | "Philips" + | "Optonique" + | "NoorKala" + | "Acer" + | "Zara" + | "H&M" + | "Gucci" + | "Dior" + | "Gucci" + | "Gucci" + | "malicious"; + +const productMapping = (productModel: string) => { + let category: ProductCategory; + let brand: ProductBrand; + + switch (productModel) { + case "SAMSUNG123": + case "SAMGALTAB567": + brand = "Samsung"; + category = productModel === "SAMSUNG123" ? "televisions" : "tablets"; + break; + + case "LGWASH456": + brand = "LG"; + category = "kitchen-appliances"; + break; + + case "LGOLED789": + brand = "LG"; + category = "televisions"; + break; + + case "BOSCHFRIDGE789": + brand = "Bosch"; + category = "kitchen-appliances"; + break; + + case "IPHONE13PRO": + case "APPLEWATCH123": + brand = "Apple"; + category = productModel === "IPHONE13PRO" ? "mobile-phones" : "smart-watch"; + break; + + case "ASUSLAP234": + brand = "Asus"; + category = "laptops"; + break; + + case "MI11PRO": + brand = "Xiaomi"; + category = "mobile-phones"; + break; + + case "SONYWH123": + case "SONYCAM456": + case "SONYSPK234": + brand = "Sony"; + category = productModel === "SONYWH123" ? "headphones" : productModel === "SONYCAM456" ? "cameras" : "speakers"; + break; + + case "JBLSPK789": + brand = "JBL"; + category = "speakers"; + break; + + case "NIKONCAM123": + brand = "Nikon"; + category = "cameras"; + break; + + case "BOSEWH789": + brand = "Bose"; + category = "headphones"; + break; + + case "HUAWEITAB1": + brand = "Huawei"; + category = "tablets"; + break; + + case "NOKIAG20": + brand = "Nokia"; + category = "mobile-phones"; + break; + + case "PANAMW234": + brand = "Panasonic"; + category = "kitchen-appliances"; + break; + + case "ANKERPB100": + brand = "Anker"; + category = "power-banks"; + break; + + case "PS5DISC": + brand = "Sony"; + category = "playstations"; + break; + + default: + brand = "malicious"; + category = "malicious"; + } + + return { brand, category }; +}; +const getBrandAndCategoryForProduct = async (product: (typeof fakeProductsData)[0]) => { + const { brand: brandName, category: categoryName } = productMapping(product.model) || {}; + + const category = await CategoryModel.findOne({ title_en: categoryName }); + + if (!category) { + throw new Error(`Category not found for product: ${product.title_fa}`); + } + const brand = await BrandModel.findOne({ title_en: brandName, category: category._id.toString() }); + + if (!brand) { + throw new Error(`Brand not found for product: ${product.title_fa}`); + } + + return { brand, category }; +}; + +const createFakeProductVariant = ( + productId: number, + shopId: string, + warrantyId: number, + colorId?: number, + sizeId?: number, + meterageId?: number, +) => { + return new ProductVariantModel({ + product: productId, + rate: faker.datatype.float({ min: 1, max: 5 }), + warranty: warrantyId, + shop: shopId, + color: colorId, + size: sizeId, + meterage: meterageId, + sellerSpecialCode: crypto.randomUUID().slice(0, 6).toUpperCase(), + market_status: faker.helpers.arrayElement([ProductMarketStatus.Marketable]), + postingTime: faker.datatype.number({ min: 1, max: 10 }), + price: { + order_limit: faker.datatype.number({ min: 1, max: 10 }), + retailPrice: faker.datatype.number({ min: 10000, max: 100000000 }), + is_specialSale: true, + discount_percent: faker.datatype.number({ min: 1, max: 50 }), + specialSale_order_limit: faker.datatype.number({ min: 1, max: 5 }), + specialSale_quantity: faker.datatype.number({ min: 1, max: 5 }), + specialSale_endDate: TimeService.convertGregorianToPersian(faker.date.future()), + }, + saleFormat: { + retail: true, + wholeSale: [ + { from: 10, to: 30, price: faker.datatype.number({ min: 10000, max: 100000000 }) }, + { from: 30, to: 50, price: faker.datatype.number({ min: 10000, max: 100000000 }) }, + { from: 50, to: 100, price: faker.datatype.number({ min: 10000, max: 100000000 }) }, + ], + }, + stock: faker.datatype.number({ min: 20, max: 100 }), + dimensions: { + package_weight: faker.datatype.number({ min: 1, max: 5 }), + package_height: faker.datatype.number({ min: 10, max: 100 }), + package_length: faker.datatype.number({ min: 10, max: 100 }), + package_width: faker.datatype.number({ min: 10, max: 100 }), + }, + }); +}; + +export const seedProduct = async (logger: Logger) => { + try { + logger.info("start seeding products"); + + // Fetch warranties and shipment methods + const warranties = await WarrantyModel.find().limit(10); + const shipments = (await ShipmentModel.find()).map((shipper) => shipper._id); + const colors = (await ColorModel.find()).map((color) => color._id); + const sizes = (await SizeModel.find()).map((size) => size._id); + const meterages = (await MeterageModel.find()).map((meterage) => meterage._id); + + if (!warranties.length) { + throw new Error("No warranties found in the database. Please seed them first."); + } + + const remainingProducts = [...fakeProductsData]; + + for (let i = 0; i < 10; i++) { + // Create fake seller + const seller = await SellerModel.create(CreateFakeSellerData()); + await WalletModel.create({ seller: seller._id.toString() }); + const shop = await ShopModel.create(createFakeShopData(faker.helpers.arrayElements(shipments, 3), seller._id.toString())); + + const sellerProducts = remainingProducts.splice(0, 5); + + for (const productData of sellerProducts) { + // Get the correct brand and category for the product + const { brand, category } = await getBrandAndCategoryForProduct(productData); + const catAttributes = await CategoryAttributeModel.find({ category: category._id.toString() }); + const attributeValues = await AttributeValueModel.find({ attribute: { $in: catAttributes.map((att) => att._id) } }); + + const specifications: ISpecifications[] = catAttributes.map((catAttr) => { + const valuesForAttribute = attributeValues + .filter((attValue) => attValue.attribute.toString() === catAttr._id.toString()) + .map((attValue) => attValue.text); + + return { + title: catAttr.title, + values: valuesForAttribute, + }; + }); + + const product = await new ProductModel({ + ...productData, + shop: shop._id, + specifications, + brand: brand._id.toString(), + category: category._id.toString(), + }).save(); + + for (let k = 0; k < 3; k++) { + const variant = await createFakeProductVariant( + product._id, + shop._id.toString(), + faker.helpers.arrayElement(warranties)._id, + productData.color ? faker.helpers.arrayElement(colors) : undefined, + productData.size ? faker.helpers.arrayElement(sizes) : undefined, + productData.meterage ? faker.helpers.arrayElement(meterages) : undefined, + ).save(); + product.variants.push(variant._id); + await product.save(); + } + } + + if (remainingProducts.length < 5) break; + } + logger.info("Seeding product completed!"); + } catch (error) { + console.error(error); + logger.error("Failed to seed product and variant:", error); + } +}; diff --git a/src/db/seeders/reportQuestion.ts b/src/db/seeders/reportQuestion.ts new file mode 100644 index 0000000..6ca4c22 --- /dev/null +++ b/src/db/seeders/reportQuestion.ts @@ -0,0 +1,61 @@ +import { Logger } from "../../core/logging/logger"; +import { ReportQuestionModel } from "../../modules/product/models/reportQuestion.model"; + +const Questions = [ + { + title: "نام کالا نادرست است", + type: "checkbox", + }, + { + title: "عکس کالا نامناسب است", + type: "checkbox", + }, + { + title: "مشخصات کالا نادرست است", + type: "checkbox", + }, + { + title: "توضیحات کالا نادرست است", + type: "checkbox", + }, + { + title: "کالا غیراصل است", + type: "checkbox", + }, + { + title: "کالا ناقض قوانین جمهوری اسلامی است", + type: "checkbox", + }, + { + title: "کالا تکراری است", + type: "checkbox", + // related_questions: [relatedQues._id], + }, +]; + +export const ReportQuesSeeder = async (logger: Logger) => { + try { + logger.info("ReportQuestion seeding started"); + + await ReportQuestionModel.deleteMany(); + for (const question of Questions) { + await ReportQuestionModel.create(question); + } + + // const relatedQues = await ReportQuestionModel.create({ + // title: "کد کالای مشابه ", + // type: "number", + // placeholder: "https://shinan.ir/product/SHP-313420/", + // }); + + // await ReportQuestionModel.create({ + // title: "کالا تکراری است", + // type: "checkbox", + // related_questions: [relatedQues._id], + // }); + + logger.info("report questions seeded successfully"); + } catch (error) { + logger.error("error seeding the report questions:", error); + } +}; diff --git a/src/db/seeders/roleSeeder.ts b/src/db/seeders/roleSeeder.ts new file mode 100644 index 0000000..0938446 --- /dev/null +++ b/src/db/seeders/roleSeeder.ts @@ -0,0 +1,36 @@ +import { Logger } from "../../core/logging/logger"; +import { RoleEnum } from "../../modules/admin/models/Abstraction/IRole"; +import { RoleModel } from "../../modules/admin/models/role.model"; + +const createDefaultRoles = async (logger: Logger) => { + logger.info("create roles started", "SEED"); + + // const roles = Object.values(RoleEnum); + const rolesWithPermissions = [ + { + role: RoleEnum.SUPERADMIN, + }, + { + role: RoleEnum.MODERATOR, + }, + { + role: RoleEnum.EDITOR, + }, + ]; + const createdRoles = await Promise.all( + rolesWithPermissions.map(async ({ role }) => { + const existingRole = await RoleModel.findOne({ name: role }); + if (!existingRole) { + return RoleModel.create({ name: role }); + } + return existingRole; + }), + ); + if (createdRoles) { + logger.info("Default roles created", "SEED"); + } else { + logger.warn("Default roles already exists", "SEED"); + } +}; + +export { createDefaultRoles }; diff --git a/src/db/seeders/shipmentSeeder.ts b/src/db/seeders/shipmentSeeder.ts new file mode 100644 index 0000000..e4a5847 --- /dev/null +++ b/src/db/seeders/shipmentSeeder.ts @@ -0,0 +1,75 @@ +import { DeliveryType } from "../../common/enums/shipment.enum"; +import { Logger } from "../../core/logging/logger"; +import { ShipmentModel } from "../../modules/shipment/models/shipment.model"; + +export const SeedShipment = async (logger: Logger) => { + const providers = [ + { + name: "پست", + description: "ارسال توسط پست", + costs: [ + { weightRange: { min: 0, max: 1 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 1, max: 5 }, cost_first: 80000, cost_rest: 2000 }, + { weightRange: { min: 5, max: 10 }, cost_first: 100000, cost_rest: 2000 }, + ], + deliveryTime: 5, // Standard (3-5 days) + deliveryType: DeliveryType.Standard, + }, + { + name: "چاپار", + description: "ارائه بهترین راه حل های جهانی لجستیک چاپار", + costs: [ + { weightRange: { min: 0, max: 2 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 2, max: 6 }, cost_first: 80000, cost_rest: 2000 }, + { weightRange: { min: 6, max: 12 }, cost_first: 100000, cost_rest: 2000 }, + ], + deliveryTime: 6, // Standard (4-6 days) + deliveryType: DeliveryType.Standard, + }, + { + name: "پیک", + description: "ارسال توسط پیک", + costs: [ + { weightRange: { min: 0, max: 1 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 1, max: 3 }, cost_first: 80000, cost_rest: 2000 }, + { weightRange: { min: 3, max: 6 }, cost_first: 100000, cost_rest: 2000 }, + ], + deliveryTime: 0, // Same Day + deliveryType: DeliveryType.SameDay, + }, + { + name: "تیپاکس", + description: "ارسال بسته های پستی در سریعترین زمان، از درب منزل با امکان پرداخت هزینه بعد از ارسال و پوشش تمام نقاط ایران", + costs: [ + { weightRange: { min: 0, max: 1 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 1, max: 5 }, cost_first: 80000, cost_rest: 2000 }, + { weightRange: { min: 5, max: 10 }, cost_first: 100000, cost_rest: 2000 }, + ], + deliveryTime: 2, // Express (1-2 days) + deliveryType: DeliveryType.Express, + }, + { + name: "باربری", + description: "ارسال توسط باربری", + costs: [ + { weightRange: { min: 0, max: 10 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 10, max: 20 }, cost_first: 80000, cost_rest: 2000 }, + { weightRange: { min: 20, max: 50 }, cost_first: 100000, cost_rest: 2000 }, + ], + deliveryTime: 7, // Freight (5-7 days) + deliveryType: DeliveryType.Standard, + }, + ]; + + try { + logger.info("ShipmentMethod seeding started"); + + for (const provider of providers) { + await ShipmentModel.create(provider); + } + + logger.info("Shipment Providers seeded successfully"); + } catch (error) { + logger.error("Error seeding Shipment providers:", error); + } +}; diff --git a/src/db/seeders/ticketSeeder.ts b/src/db/seeders/ticketSeeder.ts new file mode 100644 index 0000000..1968ec7 --- /dev/null +++ b/src/db/seeders/ticketSeeder.ts @@ -0,0 +1,38 @@ +import { Logger } from "../../core/logging/logger"; +import { TicketCategoryModel } from "../../modules/ticket/models/ticketCategory.model"; + +// Define ticket categories to seed +const ticketCategories = [ + { + title_fa: "مشکل فنی", + description: "مشکلات مربوط به سیستم، سایت یا فروشگاه.", + }, + { + title_fa: "مشکلات مالی", + description: "مشکلات مربوط به پرداخت‌ها و مسائل مالی.", + }, + { + title_fa: "پشتیبانی محصول", + description: "سوالات و پشتیبانی‌های مربوط به محصولات.", + }, + { + title_fa: "مشکلات ارسال", + description: "مشکلات مربوط به ارسال سفارشات و حمل و نقل.", + }, + { + title_fa: "درخواست راهنمایی", + description: "درخواست‌ها برای راهنمایی و کمک در مورد مسائل مختلف.", + }, +]; + +export const seedTicketCategories = async (logger: Logger) => { + try { + logger.info("start seeding the ticket category"); + + await TicketCategoryModel.insertMany(ticketCategories); + + logger.info("Ticket categories seeded successfully!"); + } catch (error) { + logger.error("Failed to seed ticket categories:", error); + } +}; diff --git a/src/db/seeders/warrantySeeders.ts b/src/db/seeders/warrantySeeders.ts new file mode 100644 index 0000000..78b3375 --- /dev/null +++ b/src/db/seeders/warrantySeeders.ts @@ -0,0 +1,47 @@ +import { faker } from "@faker-js/faker"; // Use faker for generating random data + +import { StatusEnum } from "../../common/enums/status.enum"; +import { Logger } from "../../core/logging/logger"; +import { WarrantyModel } from "../../modules/warranty/models/warranty.model"; + +const warrantyNames = [ + "Iran Warranty Co.", + "Pars Guarantee", + "Tehran Warranty Services", + "Safir Warranty", + "Farda Warranty Solutions", + "Aryan Protection", + "Mehr Warranty", + "Saman Warranty Group", + "Persian Warranty Solutions", + "Shahab After Sales", + "Payam Warranty", + "Kavir Protection", + "Tajrish Warranty", + "Sina Warranty", + "Atlas Warranty Services", + "Sepahan Warranty", + "Kish Warranty Group", + "Navid Warranty Solutions", + "Alborz Protection Services", + "Raha Warranty Solutions", +]; + +export const seedWarranty = async (logger: Logger) => { + try { + logger.info("Warranty seeding started"); + + for (const name of warrantyNames) { + await WarrantyModel.create({ + name, + status: StatusEnum.Approved, + duration: faker.datatype.number({ min: 1, max: 36 }) + " months", + logoUrl: faker.image.business(), + }); + } + + logger.info("Warranty seeding complete!"); + } catch (error) { + logger.error("Error seeding warranties:", error); + } +}; diff --git a/src/events/priceChange.ts b/src/events/priceChange.ts new file mode 100644 index 0000000..b2c8ae7 --- /dev/null +++ b/src/events/priceChange.ts @@ -0,0 +1,156 @@ +import { EventEmitter } from "events"; + +import { HydratedDocument, Types } from "mongoose"; + +import { ColorModel } from "../modules/category/models/color.model"; +import { MeterageModel } from "../modules/category/models/meterage.model"; +import { SizeModel } from "../modules/category/models/size.model"; +import { IPriceHistory } from "../modules/product/models/Abstraction/IPriceHistory"; +import { PriceHistoryModel } from "../modules/product/models/priceHistory.model"; +import { ProductObserveModel } from "../modules/product/models/productObserve.model"; +import { IUser } from "../modules/user/models/Abstraction/IUser"; +import { EmailService } from "../utils/email.service"; +import { TimeService } from "../utils/time.service"; // Use DI to inject TimeService for better testability + +enum PriceChangeEventType { + PRICE_CHANGE = "priceChange", + OBSERVER_NOTIFICATION = "observerNotification", +} + +type PriceDetails = { + product: number; + shop: string; + selling_price: number; + retail_price: number; + variantId: string; + isSpecial?: boolean; + colorId?: number; + sizeId?: number; + meterageId?: number; +}; + +type historyDetail = { shop: string; selling_price: number; retail_price: number }; + +class PriceChangeEventService extends EventEmitter { + private static instance: PriceChangeEventService; + + private constructor() { + super(); + this.subscribeToEvents(); + } + //**** */ + public static getInstance(): PriceChangeEventService { + if (!PriceChangeEventService.instance) { + PriceChangeEventService.instance = new PriceChangeEventService(); + } + return PriceChangeEventService.instance; + } + //**** */ + public emitPriceChange(priceDetails: PriceDetails): void { + this.emit(PriceChangeEventType.PRICE_CHANGE, priceDetails); + if (priceDetails.isSpecial) { + this.emitObserverNotification(priceDetails); + } + } + //**** */ + private emitObserverNotification(priceDetails: PriceDetails): void { + this.emit(PriceChangeEventType.OBSERVER_NOTIFICATION, priceDetails); + } + //**** */ + private subscribeToEvents(): void { + this.on(PriceChangeEventType.PRICE_CHANGE, this.handlePriceChange); + this.on(PriceChangeEventType.OBSERVER_NOTIFICATION, this.handleObserverNotification); + } + //**** */ + private async handlePriceChange(priceDetails: PriceDetails): Promise { + try { + const title = await this.findVariantTitle(priceDetails); + const now = TimeService.getCurrentPersianDate(); + const { colorId, sizeId, meterageId, product, ...history } = priceDetails; + + const existingHistory = await PriceHistoryModel.findOne({ title, product }); + + if (existingHistory) { + await this.appendPriceHistory(existingHistory, history, now); + console.log(`Updated price history for product ${product} with price: ${history.selling_price}`); + } else { + await this.createPriceHistory(title, product, history, now); + console.log(`Created new price history for product ${product} with price: ${history.selling_price}`); + } + } catch (error) { + console.error("Failed to record price change:", error); + } + } + //**** */ + private async handleObserverNotification(priceDetails: PriceDetails): Promise { + try { + const observers = await this.findObserversForProduct(priceDetails.product, priceDetails.variantId); + + for (const observer of observers) { + const user = observer.user as unknown as IUser; + const emailContent = this.generateEmailContent(user.fullName, observer.registeredPrice, priceDetails); + await EmailService.sendEmail({ + subject: "تخفیف ویژه", + to: user.email, + text: emailContent, + }); + console.log(`Sent notification to observer: ${user.email}`); + } + } catch (error) { + console.error("Failed to send observer notifications:", error); + } + } + + //**** */ + private async findObserversForProduct(productId: number, variantId: string) { + return await ProductObserveModel.find({ product: productId, variant: variantId }).populate("user").lean(); + } + //**** */ + private generateEmailContent(userName: string, observerPrice: number, priceDetails: PriceDetails): string { + const priceDiff = observerPrice - priceDetails.selling_price; + const message = `کاربر گرامی ${userName},\n\nقیمت محصولی که شما برای آن منتظر شده‌اید، به ${priceDetails.selling_price} تغییر کرده است.`; + + if (priceDiff > 0) { + return message + `\n\nاین قیمت ${priceDiff} تومان کمتر از قیمت هدف شما است. این فرصت را از دست ندهید!`; + } else { + return message + `\n\nاین یک پیشنهاد ویژه است که ممکن است برای شما جذاب باشد!`; + } + } + //**** */ + private async appendPriceHistory(existHistory: HydratedDocument, history: historyDetail, now: string) { + existHistory.history.push({ + ...history, + date: now, + shop: new Types.ObjectId(history.shop), + }); + await existHistory.save(); + } + //**** */ + private async createPriceHistory(title: string, product: number, history: historyDetail, now: string): Promise { + const priceHistory = new PriceHistoryModel({ title, product }); + priceHistory.history.push({ + ...history, + date: now, + shop: new Types.ObjectId(history.shop), + }); + await priceHistory.save(); + } + //**** */ + private async findVariantTitle(priceDetails: PriceDetails): Promise { + if (priceDetails.colorId) { + const color = await ColorModel.findById(priceDetails.colorId).lean(); + return color?.name || "Unknown color"; + } else if (priceDetails.sizeId) { + const size = await SizeModel.findById(priceDetails.sizeId).lean(); + return size?.value || "Unknown size"; + } else if (priceDetails.meterageId) { + const meterage = await MeterageModel.findById(priceDetails.meterageId).lean(); + return meterage?.value || "Unknown meterage"; + } + return ""; + } +} + +const instance = PriceChangeEventService.getInstance(); + +export { instance as PriceChangeEvent, PriceChangeEventService, PriceChangeEventType }; diff --git a/src/modules/Coupon/DTO/createCoupon.dto.ts b/src/modules/Coupon/DTO/createCoupon.dto.ts new file mode 100644 index 0000000..133e4f1 --- /dev/null +++ b/src/modules/Coupon/DTO/createCoupon.dto.ts @@ -0,0 +1,84 @@ +import { Expose } from "class-transformer"; +import { IsArray, IsBoolean, IsMongoId, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; + +export class CreateCouponDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 10, description: "Discount percentage for the coupon" }) + discountPercentage?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 10000, description: "Flat discount amount" }) + discountAmount?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon" }) + usageLimit: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon per user" }) + userUsageLimit: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 100000, description: "Minimum purchase amount to use the coupon in toman" }) + minPurchaseAmount: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsMongoId({ each: true }) + @ApiProperty({ type: "Array", example: ["60d21b4967d0d8992e610c85"], description: "categories for the coupon" }) + category?: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ type: "Array", example: [100001, 100102], description: "List of included product IDs" }) + includedProducts?: number[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ type: "Array", example: [100001, 100102], description: "List of excluded product IDs" }) + excludedProducts?: number[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", example: "کد تخفیف", description: "Description of the coupon" }) + description: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", example: false, description: "Whether the coupon applies to special sales" }) + includeSpecialSale: boolean; + + @Expose() + @IsNotEmpty() + @IsValidPersianDate() + @ApiProperty({ type: "string", example: "1401/06/05", description: "Coupon expiration date in jalali format" }) + expirationDate: string; +} diff --git a/src/modules/Coupon/DTO/updateCoupon.dto.ts b/src/modules/Coupon/DTO/updateCoupon.dto.ts new file mode 100644 index 0000000..6a8172f --- /dev/null +++ b/src/modules/Coupon/DTO/updateCoupon.dto.ts @@ -0,0 +1,63 @@ +import { Expose } from "class-transformer"; +import { IsArray, IsMongoId, IsNotEmpty, IsNumber, IsOptional } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class UpdateCouponDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 10, description: "Discount percentage for the coupon" }) + discountPercentage?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 10000, description: "Flat discount amount" }) + discountAmount?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon" }) + usageLimit?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 5, description: "Maximum number of uses for the coupon per user" }) + userUsageLimit?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", example: 100000, description: "Minimum purchase amount to use the coupon in toman" }) + minPurchaseAmount?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsMongoId({ each: true }) + @ApiProperty({ type: "Array", example: ["60d21b4967d0d8992e610c85"], description: "categories for the coupon" }) + category?: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ type: "Array", example: [100001, 100102], description: "List of included product IDs" }) + includedProducts?: number[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ type: "Array", example: [100001, 100102], description: "List of excluded product IDs" }) + excludedProducts?: number[]; +} diff --git a/src/modules/Coupon/DTO/validateCoupon.dto.ts b/src/modules/Coupon/DTO/validateCoupon.dto.ts new file mode 100644 index 0000000..d5c6d4b --- /dev/null +++ b/src/modules/Coupon/DTO/validateCoupon.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CartShipmentItemModel } from "../../cart/models/cartShipmentItem.model"; + +export class ValidateCouponDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", example: "DISCOUNT2024" }) + code: string; + + @Expose() + @IsNotEmpty() + @IsValidId(CartShipmentItemModel) + @ApiProperty({ type: "string", example: "5f7b1b1b1b1b1b1b1b1b1b1b1", description: "cartShipment item id" }) + cartShipmentItemId: string; +} diff --git a/src/modules/Coupon/coupon.controller.ts b/src/modules/Coupon/coupon.controller.ts new file mode 100644 index 0000000..7651441 --- /dev/null +++ b/src/modules/Coupon/coupon.controller.ts @@ -0,0 +1,96 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { CouponService } from "./coupon.service"; +import { HttpStatus } from "../../common"; +import { CreateCouponDTO } from "./DTO/createCoupon.dto"; +import { UpdateCouponDTO } from "./DTO/updateCoupon.dto"; +import { ValidateCouponDTO } from "./DTO/validateCoupon.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/coupon") +@ApiTags("Coupon") +class CouponController extends BaseController { + @inject(IOCTYPES.CouponService) couponService: CouponService; + + @ApiOperation("get all seller created coupon ==> login as seller") + @ApiResponse("successful") + @ApiAuth() + @httpGet("", Guard.authSeller()) + public async getSellerCoupons(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.couponService.getSellerCoupons(seller._id.toString(), OwnerRef.SELLER); + return this.response(data); + } + + @ApiOperation("get a single coupon") + @ApiParam("id", "coupon id", true) + @ApiAuth() + @httpGet("/:id", Guard.authSeller()) + public async getSingleCoupon(@requestParam("id") couponId: string) { + const data = await this.couponService.getSingleCoupon(couponId); + return this.response(data); + } + + @ApiOperation("create a Coupon ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateCouponDTO) + @ApiAuth() + @httpPost("", rateLimit(appConfig.rate), Guard.authSeller(), ValidationMiddleware.validateInput(CreateCouponDTO)) + public async createCoupon(@requestBody() createDto: CreateCouponDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.couponService.createCoupon(createDto, seller._id.toString(), OwnerRef.SELLER); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("deactivate a coupon ==> login as seller") + @ApiResponse("successful") + @ApiAuth() + @ApiParam("id", "coupon id", true) + @httpPatch("/:id/status", Guard.authSeller()) + public async changeCouponStatus(@request() req: Request, @requestParam("id") couponId: string) { + const seller = req.user as ISeller; + const data = await this.couponService.changeCouponStatus(seller._id.toString(), couponId, OwnerRef.SELLER); + return this.response(data); + } + + @ApiOperation("update a seller coupon ==> login as seller") + @ApiResponse("successful") + @ApiModel(UpdateCouponDTO) + @ApiParam("id", "coupon id", true) + @ApiAuth() + @httpPatch("/:id", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateCouponDTO)) + public async updateSellerCoupon( + @requestBody() updateDto: UpdateCouponDTO, + @request() req: Request, + @requestParam("id") couponId: string, + ) { + const seller = req.user as ISeller; + const data = await this.couponService.updateCoupon(updateDto, seller._id.toString(), couponId, OwnerRef.SELLER); + return this.response(data); + } + + @ApiOperation("add a coupon to user cart ===> login as user") + @ApiResponse("successful") + @ApiResponse("bad request", HttpStatus.BadRequest) + @ApiModel(ValidateCouponDTO) + @ApiAuth() + @httpPost("/add-coupon", Guard.authUser(), ValidationMiddleware.validateInput(ValidateCouponDTO)) + public async addCoupon(@requestBody() validateDto: ValidateCouponDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.couponService.applyCouponDiscount(validateDto, user._id.toString()); + return this.response(data); + } +} + +export { CouponController }; diff --git a/src/modules/Coupon/coupon.repository.ts b/src/modules/Coupon/coupon.repository.ts new file mode 100644 index 0000000..19b770a --- /dev/null +++ b/src/modules/Coupon/coupon.repository.ts @@ -0,0 +1,24 @@ +import { ICoupon } from "./models/Abstraction/ICoupon"; +import { ICouponUsage } from "./models/Abstraction/ICouponUsage"; +import { CouponModel } from "./models/coupon.model"; +import { CouponUsageModel } from "./models/couponUsage.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class CouponRepo extends BaseRepository { + constructor() { + super(CouponModel); + } +} +export function createCouponRepo(): CouponRepo { + return new CouponRepo(); +} + +export class CouponUsageRepo extends BaseRepository { + constructor() { + super(CouponUsageModel); + } +} + +export function createCouponUsageRepo(): CouponUsageRepo { + return new CouponUsageRepo(); +} diff --git a/src/modules/Coupon/coupon.service.ts b/src/modules/Coupon/coupon.service.ts new file mode 100644 index 0000000..74dd195 --- /dev/null +++ b/src/modules/Coupon/coupon.service.ts @@ -0,0 +1,287 @@ +import { randomBytes } from "crypto"; + +import { inject, injectable } from "inversify"; +import { ClientSession, isValidObjectId, startSession } from "mongoose"; + +import { CouponRepo, CouponUsageRepo } from "./coupon.repository"; +import { CreateCouponDTO } from "./DTO/createCoupon.dto"; +import { UpdateCouponDTO } from "./DTO/updateCoupon.dto"; +import { ValidateCouponDTO } from "./DTO/validateCoupon.dto"; +import { CartMessage, CommonMessage, CouponMessage, ShopMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { TimeService } from "../../utils/time.service"; +import { CartRepository, CartShipItemRepo } from "../cart/cart.repository"; +import { CartService } from "../cart/cart.service"; +import { ICartShipmentItem } from "../cart/models/Abstraction/ICartShipmentItem"; +import { PaymentService } from "../payment/providers/payment.service"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; + +@injectable() +class CouponService { + @inject(IOCTYPES.CouponRepo) couponRepo: CouponRepo; + @inject(IOCTYPES.CouponUsageRepo) couponUsageRepo: CouponUsageRepo; + @inject(IOCTYPES.PaymentService) paymentService: PaymentService; + @inject(IOCTYPES.CartRepository) cartRepo: CartRepository; + @inject(IOCTYPES.CartService) cartService: CartService; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + @inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo; + + async getSellersCoupons() { + const coupons = await this.couponRepo.model.aggregate([ + { + $match: { + deleted: false, + }, + }, + { + $lookup: { + from: "shops", + localField: "shopId", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $match: { + "shop.ownerRef": OwnerRef.SELLER, + "shop.deleted": false, + }, + }, + { + $project: { + shop: 0, + }, + }, + ]); + + return { coupons }; + } + + async getSellerCoupons(ownerId: string, ownerRef: OwnerRef) { + const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + return { + coupons: await this.couponRepo.model.find({ shopId: shop._id, deleted: false }), + }; + } + + async getSingleCoupon(couponId: string) { + if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId); + + const coupon = await this.couponRepo.model.findById(couponId); + + if (!coupon) throw new BadRequestError(CouponMessage.InvalidId); + return { coupon }; + } + //############################## + + async createCoupon(createDto: CreateCouponDTO, ownerId: string, ownerRef: OwnerRef) { + const { discountAmount, discountPercentage, includedProducts, excludedProducts } = createDto; + + if (!discountAmount && !discountPercentage) throw new BadRequestError(CouponMessage.DiscountAmountOrPercentageRequired); + + if (discountAmount && discountPercentage) throw new BadRequestError(CouponMessage.BothDiscountOrPercentageShouldNotSend); + + if (includedProducts && excludedProducts) throw new BadRequestError(CouponMessage.BothIncludedAndExcludedProductsShouldNotSend); + + const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const code = this.generateCouponCode(); + const coupon = await this.couponRepo.model.create({ + shopId: shop._id, + code, + ...createDto, + }); + + return { coupon }; + } + + //############################## + + async updateCoupon(updateDto: UpdateCouponDTO, ownerId: string, couponId: string, ownerRef: OwnerRef) { + if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId); + + const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const { discountAmount, discountPercentage, includedProducts, excludedProducts } = updateDto; + + if (!discountAmount && !discountPercentage) throw new BadRequestError(CouponMessage.DiscountAmountOrPercentageRequired); + + if (discountAmount && discountPercentage) throw new BadRequestError(CouponMessage.BothDiscountOrPercentageShouldNotSend); + + if (includedProducts && excludedProducts) throw new BadRequestError(CouponMessage.BothIncludedAndExcludedProductsShouldNotSend); + + const updatedCoupon = await this.couponRepo.model.findOneAndUpdate( + { _id: couponId, shopId: shop._id }, + { ...updateDto }, + { new: true }, + ); + + if (!updatedCoupon) throw new BadRequestError(CouponMessage.InvalidId); + + return { + message: CommonMessage.Updated, + coupon: updatedCoupon, + }; + } + + //############################## + async changeCouponStatus(ownerId: string, couponId: string, ownerRef: OwnerRef) { + if (!isValidObjectId(couponId)) throw new BadRequestError(CouponMessage.InvalidId); + + const shop = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const coupon = await this.couponRepo.model.findOne({ _id: couponId, shopId: shop._id }); + if (!coupon) throw new BadRequestError(CouponMessage.InvalidId); + + const updatedCoupon = await this.couponRepo.model.findOneAndUpdate( + { _id: couponId, shopId: shop._id }, + { isActive: !coupon.isActive }, + { new: true }, + ); + + return { + message: CommonMessage.Updated, + coupon: updatedCoupon, + }; + } + + //############################## + + async applyCouponDiscount(validateDto: ValidateCouponDTO, userId: string) { + const session = await startSession(); + session.startTransaction(); + try { + const { price, cartShipmentItems } = await this.paymentService.getPaymentInfoS(userId); + + const userCart = await this.cartRepo.model.findOne({ user: userId }).session(session); + if (!userCart) throw new BadRequestError(CartMessage.CartNotFound); + + // if (cart.coupon_discount) throw new BadRequestError(CouponMessage.CouponAlreadyInCart); + const cartShipmentItem = cartShipmentItems.find((item) => item._id.toString() === validateDto.cartShipmentItemId); + if (!cartShipmentItem) throw new BadRequestError(CartMessage.InvalidCartShipmentItem); + const validCartShipmentItem = await this.cartShipItemRepo.model.findById(cartShipmentItem._id).session(session); + if (!validCartShipmentItem) throw new BadRequestError(CartMessage.InvalidCartShipmentItem); + + if (cartShipmentItem.totalCouponDiscount) throw new BadRequestError(CouponMessage.CouponAlreadyInCart); + + const { total_payable_price } = price; + + const { coupon } = await this.validateCoupon(validateDto.code, cartShipmentItem.totalPaymentPrice, userId, session); + + let discount = 0; + + if (coupon.discountAmount) { + discount = coupon.discountAmount; + } else if (coupon.discountPercentage) { + discount = (total_payable_price * coupon.discountPercentage) / 100; + } + + if (coupon.category && coupon.category.length > 0) { + await this.checkCartItemCategory( + userId, + coupon.category.map((cat) => cat._id.toString()), + ); + } + + if (coupon.includedProducts && coupon.includedProducts.length > 0) { + await this.checkCartItemIncludedProducts(cartShipmentItem, coupon.includedProducts); + } + + if (coupon.excludedProducts && coupon.excludedProducts.length > 0) { + await this.checkCartItemExcludedProducts(cartShipmentItem, coupon.excludedProducts); + } + + await this.couponUsageRepo.model.findOneAndUpdate( + { user: userId, coupon: coupon._id }, + { $inc: { usageCount: 1 } }, + { new: true, upsert: true, session }, + ); + + cartShipmentItem.totalCouponDiscount = discount; + cartShipmentItem.totalPaymentPrice = cartShipmentItem.totalPaymentPrice - discount; + userCart.coupon_discount = discount; + coupon.usageCount += 1; + + await validCartShipmentItem.save({ session }); + await userCart.save({ session }); + await coupon.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { message: CouponMessage.CouponAdded, afterDiscount: total_payable_price - discount }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + + throw error; + } + } + + //================================> helper method + private generateCouponCode(length = 8) { + return randomBytes(length).toString("hex").slice(0, length).toUpperCase(); + } + + async checkCartItemExcludedProducts(shipmentItem: ICartShipmentItem, excludedProducts: number[]) { + const items = shipmentItem.shipmentItems.filter((item) => excludedProducts.includes(item.product)); + console.log({ items }); + if (items.length > 0) throw new BadRequestError(CouponMessage.InvalidProduct); + return true; + } + + async checkCartItemIncludedProducts(shipmentItem: ICartShipmentItem, includedProducts: number[]) { + const items = shipmentItem.shipmentItems.filter((item) => includedProducts.includes(item.product)); + if (!items.length) throw new BadRequestError(CouponMessage.InvalidProduct); + return true; + } + + async checkCartItemCategory(userId: string, category: string[]) { + const { cart } = await this.cartService.getCartS(userId); + const items = cart.items.filter((item) => category.includes(item.product.category._id)); + if (items.length === 0) throw new BadRequestError(CouponMessage.InvalidCategory); + return true; + } + + async removeCouponByAdmin(couponId: string) { + const deletedCoupon = await this.couponRepo.model.findByIdAndUpdate(couponId, { deleted: true }, { new: true }); + if (!deletedCoupon) throw new BadRequestError(CouponMessage.NotFound); + return { message: CommonMessage.Deleted }; + } + + //############################## + private async validateCoupon(couponCode: string, totalPrice: number, userId: string, session: ClientSession) { + const coupon = await this.couponRepo.model.findOne({ code: couponCode, isActive: true, deleted: false }).session(session); + + if (!coupon) throw new BadRequestError(CouponMessage.NotFound); + + const expired = TimeService.isPersianDateExpired(coupon.expirationDate); + if (expired) throw new BadRequestError(CouponMessage.Expired); + + if (coupon.usageLimit && coupon.usageCount >= coupon.usageLimit) throw new BadRequestError(CouponMessage.UsageLimit); + + const userUsage = await this.couponUsageRepo.model.findOne({ coupon: coupon._id, user: userId }).session(session); + + if (userUsage && userUsage.usageCount >= coupon.userUsageLimit) throw new BadRequestError(CouponMessage.UserCouponUsageLimit); + + if (coupon.minPurchaseAmount && totalPrice < coupon.minPurchaseAmount) { + throw new BadRequestError([ + CouponMessage.AmountIsNotSatisfied, + `حداقل مبلغ سبد خرید برای استفاده از این کد تخفیف ${coupon.minPurchaseAmount}`, + ]); + } + + return { coupon }; + } +} + +export { CouponService }; diff --git a/src/modules/Coupon/models/Abstraction/ICoupon.ts b/src/modules/Coupon/models/Abstraction/ICoupon.ts new file mode 100644 index 0000000..8e138f7 --- /dev/null +++ b/src/modules/Coupon/models/Abstraction/ICoupon.ts @@ -0,0 +1,20 @@ +import { Types } from "mongoose"; + +export interface ICoupon { + shopId: Types.ObjectId; + discountAmount: number; + discountPercentage: number; + usageLimit: number; + usageCount: number; + userUsageLimit: number; + code: string; + minPurchaseAmount: number; + category: Types.ObjectId[]; + includedProducts: number[]; + excludedProducts: number[]; + description: string; + isActive: boolean; + includeSpecialSale: boolean; + expirationDate: string; + deleted: boolean; +} diff --git a/src/modules/Coupon/models/Abstraction/ICouponUsage.ts b/src/modules/Coupon/models/Abstraction/ICouponUsage.ts new file mode 100644 index 0000000..09d9d2f --- /dev/null +++ b/src/modules/Coupon/models/Abstraction/ICouponUsage.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface ICouponUsage { + coupon: Types.ObjectId; + user: Types.ObjectId; + usageCount: number; +} diff --git a/src/modules/Coupon/models/coupon.model.ts b/src/modules/Coupon/models/coupon.model.ts new file mode 100644 index 0000000..d18c3e7 --- /dev/null +++ b/src/modules/Coupon/models/coupon.model.ts @@ -0,0 +1,29 @@ +import { Schema, model } from "mongoose"; + +import { ICoupon } from "./Abstraction/ICoupon"; + +const CouponSchema = new Schema( + { + shopId: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + discountPercentage: { type: Number, default: 0 }, + discountAmount: { type: Number, default: 0 }, + usageLimit: { type: Number, default: null }, + usageCount: { type: Number, default: 0 }, + userUsageLimit: { type: Number, default: null }, + code: { type: String, required: true, unique: true }, + minPurchaseAmount: { type: Number, default: null }, + category: { type: [Schema.Types.ObjectId], ref: "Category", default: [] }, + includedProducts: { type: [Number], ref: "Product", default: [] }, + excludedProducts: { type: [Number], ref: "Product", default: [] }, + description: { type: String, default: null }, + isActive: { type: Boolean, default: true }, + includeSpecialSale: { type: Boolean, default: true }, + expirationDate: { type: String, required: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const CouponModel = model("Coupon", CouponSchema); + +export { CouponModel }; diff --git a/src/modules/Coupon/models/couponUsage.model.ts b/src/modules/Coupon/models/couponUsage.model.ts new file mode 100644 index 0000000..5486c70 --- /dev/null +++ b/src/modules/Coupon/models/couponUsage.model.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { ICouponUsage } from "./Abstraction/ICouponUsage"; + +const CouponUsageSchema = new Schema( + { + coupon: { type: Schema.Types.ObjectId, ref: "Coupon", required: true }, + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + usageCount: { type: Number, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +CouponUsageSchema.index({ coupon: 1, user: 1 }, { unique: true }); +const CouponUsageModel = model("CouponUsage", CouponUsageSchema); +export { CouponUsageModel }; diff --git a/src/modules/IPG/PaymentGateway.ts b/src/modules/IPG/PaymentGateway.ts new file mode 100644 index 0000000..89d296d --- /dev/null +++ b/src/modules/IPG/PaymentGateway.ts @@ -0,0 +1,52 @@ +import { inject, injectable } from "inversify"; + +import { AsanPardakhtGateway } from "./gateways/asanpardakht"; +import { ZarinPalGateway } from "./gateways/zarinpal"; +import { IPaymentGateway, NewPaymentData, VerifyPaymentData } from "./interface/IPG"; +import { GatewayProvider } from "../../common/enums/payment.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class PaymentGateway implements IPaymentGateway { + @inject(IOCTYPES.ZarinPalGateway) zarinPalGateway: ZarinPalGateway; + @inject(IOCTYPES.AsanPardakhtGateway) asanpardakhtGateway: AsanPardakhtGateway; + + public async requestPayment(gatewayType: GatewayProvider, data: NewPaymentData): Promise { + switch (gatewayType) { + case GatewayProvider.Zarinpal: + return await this.zarinPalGateway.processPayment(data.amount, data.description, data.email, data.mobile, data.callbackPath); + case GatewayProvider.Asanpardakht: + //TODO:fix this + return this.asanpardakhtGateway.token(); + default: + throw new BadRequestError(`Payment provider ${gatewayType} is not supported`); + } + } + + public async verifyPayment(gatewayType: GatewayProvider, data: VerifyPaymentData): Promise { + switch (gatewayType) { + case GatewayProvider.Zarinpal: + return await this.zarinPalGateway.verifyPayment(data.authority, data.amount); + case GatewayProvider.Asanpardakht: + //TODO:fix this + return this.asanpardakhtGateway.time(); + default: + throw new BadRequestError(`Payment provider ${gatewayType} is not supported`); + } + } + + public async retryPayment(gatewayType: GatewayProvider, authority: string) { + switch (gatewayType) { + case GatewayProvider.Zarinpal: + return await this.zarinPalGateway.retryPayment(authority); + case GatewayProvider.Asanpardakht: + //TODO:fix this + return this.asanpardakhtGateway.token(); + default: + throw new BadRequestError(`Payment provider ${gatewayType} is not supported`); + } + } +} + +export { PaymentGateway }; diff --git a/src/modules/IPG/gateways/asanpardakht.ts b/src/modules/IPG/gateways/asanpardakht.ts new file mode 100644 index 0000000..157e873 --- /dev/null +++ b/src/modules/IPG/gateways/asanpardakht.ts @@ -0,0 +1,141 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import { injectable } from "inversify"; + +@injectable() +class AsanPardakhtGateway { + private invoiceId: string | null = null; + private amount: number | null = null; + private callBackURL = `${process.env.SITE_URL}/verify/ap`; + + private readonly GatewayAuth = { + user: process.env.ASANPARDAKHT_USER, + password: process.env.ASANPARDAKHT_PASS, + merchant_id: process.env.ASANPARDAKHT_MERCHANT_ID, + merchantConfigID: process.env.ASANPARDAKHT_MERCHANT_CONFIG_ID, + }; + + // API endpoint URLs + private readonly TokenURL = "v1/Token"; + private readonly TimeURL = "v1/Time"; + private readonly TranResultURL = "v1/TranResult"; + private readonly CardHashURL = "v1/CardHash"; + private readonly SettlementURL = "v1/Settlement"; + private readonly VerifyURL = "v1/Verify"; + private readonly CancelURL = "v1/Cancel"; + private readonly ReverseURL = "v1/Reverse"; + private readonly GatewayApiURL = "https://ipgrest.asanpardakht.ir"; + + setInvoiceId(id: string): this { + this.invoiceId = id; + return this; + } + + setAmount(amount: number): this { + this.amount = amount; + return this; + } + + async time(): Promise<{ code: number; content: any }> { + return await this.callAPI("GET", this.TimeURL); + } + + async TranResult(): Promise<{ code: number; content: any }> { + const res = await this.callAPI( + "GET", + `${this.TranResultURL}?${new URLSearchParams({ + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + localInvoiceId: this.invoiceId!, + }).toString()}`, + ); + return { code: res.code, content: JSON.parse(res.content) }; + } + + async CardHash(): Promise<{ code: number; content: any }> { + return await this.callAPI( + "GET", + `${this.CardHashURL}?${new URLSearchParams({ + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + localInvoiceId: this.invoiceId!, + }).toString()}`, + ); + } + + async token(): Promise<{ code: number; content: any }> { + if (this.callBackURL) { + this.callBackURL += this.callBackURL.includes("?") ? "&" : "?"; + } + const currentDate = new Date().toISOString().split("T").join(" ").split(".")[0].replace(/[-:]/g, ""); + return await this.callAPI("POST", this.TokenURL, { + serviceTypeId: 1, + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + localInvoiceId: this.invoiceId!, + amountInRials: this.amount!, + localDate: currentDate, + callbackURL: `${this.callBackURL}${new URLSearchParams({ invoice: this.invoiceId! }).toString()}`, + paymentId: 0, + additionalData: "", + }); + } + + async settlement(transId: string): Promise<{ code: number; content: any }> { + return await this.callAPI("POST", this.SettlementURL, { + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + payGateTranId: transId, + }); + } + + async verify(transId: string): Promise<{ code: number; content: any }> { + return await this.callAPI("POST", this.VerifyURL, { + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + payGateTranId: transId, + }); + } + + async reverse(transId: string): Promise<{ code: number; content: any }> { + return await this.callAPI("POST", this.ReverseURL, { + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + payGateTranId: transId, + }); + } + + async cancel(transId: string): Promise<{ code: number; content: any }> { + return await this.callAPI("POST", this.CancelURL, { + merchantConfigurationId: this.GatewayAuth.merchantConfigID!, + payGateTranId: transId, + }); + } + + /** + * Internal method to call the API. + * @param method - The HTTP method to use. + * @param endpoint - The API endpoint to call. + * @param data - Optional data to send with the request. + * @returns A promise resolving to the API response. + */ + private async callAPI(method: "GET" | "POST", endpoint: string, data: any = null): Promise<{ code: number; content: any }> { + const url = `${this.GatewayApiURL}/${endpoint}`; + const options: AxiosRequestConfig = { + method, + url, + headers: { + Accept: "application/json", + Usr: this.GatewayAuth.user || "", + Pwd: this.GatewayAuth.password || "", + "Content-Type": "application/json", + }, + data: data ? JSON.stringify(data) : null, + }; + + try { + const response: AxiosResponse = await axios(options); + return { content: response.data, code: response.status }; + } catch (error: any) { + if (error.response) { + return { content: error.response.data, code: error.response.status }; + } else { + return { content: error.message, code: 500 }; + } + } + } +} +export { AsanPardakhtGateway }; diff --git a/src/modules/IPG/gateways/zarinpal.ts b/src/modules/IPG/gateways/zarinpal.ts new file mode 100644 index 0000000..29f826c --- /dev/null +++ b/src/modules/IPG/gateways/zarinpal.ts @@ -0,0 +1,64 @@ +import axios from "axios"; +import { injectable } from "inversify"; + +import { ZarinPalPGNewArgs, ZarinPalPGNewRequestData, ZarinPalPGVerifyData } from "../../../common/types/paymentGateway.type"; +import { InternalError } from "../../../core/app/app.errors"; +import { Logger } from "../../../core/logging/logger"; + +@injectable() +class ZarinPalGateway { + private logger = new Logger(ZarinPalGateway.name); + + private gatewayApiUrl: string = `https://${process.env.IPG_TYPE}.zarinpal.com/pg`; //or api subdomain + private callBackURL = `${process.env.SITE_URL}/verify/Zarinpal/`; + private MERCHANT_ID: string = process.env.ZARINPAL_MERCHANT_ID; + private requestHeader = { "Content-Type": "application/json", Accept: "application/json" }; + + async processPayment(amount: number, description: string, email: string, mobile: string, callBackPath: string) { + try { + const purchaseData: ZarinPalPGNewArgs = { + merchant_id: this.MERCHANT_ID, + amount, + callback_url: `${this.callBackURL}${callBackPath}`, + description, + currency: "IRT" as const, + metadata: { email, mobile }, + }; + + const response = await axios.post(`${this.gatewayApiUrl}/v4/payment/request.json`, purchaseData, { headers: this.requestHeader }); + const { data } = response.data as ZarinPalPGNewRequestData; + + return { + redirectUrl: `${this.gatewayApiUrl}/StartPay/${data.authority}`, + message: data.message, + authority: data.authority, + }; + } catch (error) { + this.logger.error("error in payment process", error); + throw new InternalError(["error processing payment"]); + } + } + + async verifyPayment(authority: string, amount: number) { + try { + //TODO:check if amount should be in toman or riyal + const verifyData = { authority, amount, merchant_id: this.MERCHANT_ID }; + const response = await axios.post(`${this.gatewayApiUrl}/v4/payment/verify.json`, verifyData, { headers: this.requestHeader }); + + const { data } = response.data as ZarinPalPGVerifyData; + return data; + } catch (error) { + this.logger.error("error in payment verification", error); + throw new InternalError(["error in payment verification"]); + } + } + + async retryPayment(authority: string) { + return { + redirectUrl: `${this.gatewayApiUrl}/StartPay/${authority}`, + authority: authority, + }; + } +} + +export { ZarinPalGateway }; diff --git a/src/modules/IPG/interface/IPG.ts b/src/modules/IPG/interface/IPG.ts new file mode 100644 index 0000000..df5d23f --- /dev/null +++ b/src/modules/IPG/interface/IPG.ts @@ -0,0 +1,37 @@ +import { GatewayProvider } from "../../../common/enums/payment.enum"; + +export interface IPaymentGateway { + /** + * + * @param gatewayType + * @param data + */ + requestPayment(gatewayType: GatewayProvider, data: NewPaymentData): Promise; + + /** + * + * @param gatewayType + * @param data + */ + verifyPayment(gatewayType: GatewayProvider, data: VerifyPaymentData): Promise; + + /** + * + * @param gatewayType + * @param authority + */ + retryPayment(gatewayType: GatewayProvider, authority: string): Promise; +} + +export interface NewPaymentData { + amount: number; + description: string; + email: string; + mobile: string; + callbackPath: string; +} + +export interface VerifyPaymentData { + authority: string; + amount: number; +} diff --git a/src/modules/about-us/DTO/createAboutUsRequest.dto.ts b/src/modules/about-us/DTO/createAboutUsRequest.dto.ts new file mode 100644 index 0000000..c1c2c0f --- /dev/null +++ b/src/modules/about-us/DTO/createAboutUsRequest.dto.ts @@ -0,0 +1,34 @@ +import { Expose, plainToClass } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IAboutUs } from "../model/Abstractions/IAboutUs"; + +export class CreateAboutUsDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of about us", example: "عنوان ۱" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "description of about us", example: "توضیحات درباره ما" }) + description: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "image url of about us", example: "https://loremflickr.com/454/340" }) + imageUrl: string; + + public static transformAboutUs(data: IAboutUs): CreateAboutUsDTO { + const AboutUsDto = plainToClass(CreateAboutUsDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return AboutUsDto; + } +} diff --git a/src/modules/about-us/aboutUs.controller.ts b/src/modules/about-us/aboutUs.controller.ts new file mode 100644 index 0000000..4ae21ad --- /dev/null +++ b/src/modules/about-us/aboutUs.controller.ts @@ -0,0 +1,22 @@ +import { inject } from "inversify"; +import { controller, httpGet } from "inversify-express-utils"; + +import { AboutUsService } from "./aboutUs.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/about-us") +@ApiTags("AboutUs") +export class AboutUsController extends BaseController { + @inject(IOCTYPES.AboutUsService) aboutUsService: AboutUsService; + + @ApiOperation("get a list of about us") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("") + public async getAllAboutUs() { + const data = await this.aboutUsService.getAllAboutUs(); + return this.response(data, HttpStatus.Ok); + } +} diff --git a/src/modules/about-us/aboutUs.repository.ts b/src/modules/about-us/aboutUs.repository.ts new file mode 100644 index 0000000..047b3a6 --- /dev/null +++ b/src/modules/about-us/aboutUs.repository.ts @@ -0,0 +1,13 @@ +import { AboutUsModel } from "./model/aboutUs.model"; +import { IAboutUs } from "./model/Abstractions/IAboutUs"; +import { BaseRepository } from "../../common/base/repository"; + +export class AboutUsRepo extends BaseRepository { + constructor() { + super(AboutUsModel); + } +} + +export function CreateAboutUsRepo(): AboutUsRepo { + return new AboutUsRepo(); +} diff --git a/src/modules/about-us/aboutUs.service.ts b/src/modules/about-us/aboutUs.service.ts new file mode 100644 index 0000000..93329bc --- /dev/null +++ b/src/modules/about-us/aboutUs.service.ts @@ -0,0 +1,45 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { AboutUsRepo } from "./aboutUs.repository"; +import { CreateAboutUsDTO } from "./DTO/createAboutUsRequest.dto"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class AboutUsService { + @inject(IOCTYPES.AboutUsRepo) aboutUsRepo: AboutUsRepo; + + async createAboutUs(createDto: CreateAboutUsDTO) { + await this.aboutUsRepo.model.create({ + ...createDto, + }); + + return { + message: CommonMessage.Created, + }; + } + + async getAllAboutUs() { + const aboutUs = await this.aboutUsRepo.model.find(); + return { aboutUs }; + } + + async getById(aboutId: string) { + if (!isValidObjectId(aboutId)) throw new BadRequestError(CommonMessage.NotValidId); + const aboutUs = await this.aboutUsRepo.model.findById(aboutId); + if (!aboutUs) throw new BadRequestError(CommonMessage.NotFoundById); + return { + aboutUs, + }; + } + + async delete(aboutId: string) { + if (!isValidObjectId(aboutId)) throw new BadRequestError(CommonMessage.NotValidId); + await this.aboutUsRepo.delete(aboutId); + return { + message: CommonMessage.Deleted, + }; + } +} diff --git a/src/modules/about-us/model/Abstractions/IAboutUs.ts b/src/modules/about-us/model/Abstractions/IAboutUs.ts new file mode 100644 index 0000000..ba8113f --- /dev/null +++ b/src/modules/about-us/model/Abstractions/IAboutUs.ts @@ -0,0 +1,5 @@ +export interface IAboutUs { + title: string; + description: string; + imageUrl: string; +} diff --git a/src/modules/about-us/model/aboutUs.model.ts b/src/modules/about-us/model/aboutUs.model.ts new file mode 100644 index 0000000..91791bd --- /dev/null +++ b/src/modules/about-us/model/aboutUs.model.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +import { IAboutUs } from "./Abstractions/IAboutUs"; + +const AboutUsSchema = new Schema( + { + title: { type: String, required: true }, + description: { type: String, required: true }, + imageUrl: { type: String, required: true }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); +const AboutUsModel = model("AboutUs", AboutUsSchema); + +export { AboutUsModel }; diff --git a/src/modules/address/DTO/address.dto.ts b/src/modules/address/DTO/address.dto.ts new file mode 100644 index 0000000..6090621 --- /dev/null +++ b/src/modules/address/DTO/address.dto.ts @@ -0,0 +1,59 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { IAddress } from "../models/Abstraction/IAddress"; + +export class CityDTO { + @Expose() + _id: number; + + @Expose() + name: string; + + @Expose() + province: number; +} + +export class ProvinceDTO { + @Expose() + _id: number; + + @Expose() + name: string; +} + +export class AddressDTO { + @Expose() + _id: string; + + @Expose() + address: string; + + @Expose() + @Type(() => CityDTO) + city: CityDTO; + + @Expose() + @Type(() => ProvinceDTO) + province: ProvinceDTO; + + @Expose() + postalCode: string; + + @Expose() + plaque: string; + + @Expose() + lat: string; + + @Expose() + lon: string; + + public static transformAddress(data: IAddress): AddressDTO { + const addressDTO = plainToInstance(AddressDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return addressDTO; + } +} diff --git a/src/modules/address/DTO/userAddress.dto.ts b/src/modules/address/DTO/userAddress.dto.ts new file mode 100644 index 0000000..cf759a7 --- /dev/null +++ b/src/modules/address/DTO/userAddress.dto.ts @@ -0,0 +1,99 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsNumberString, IsOptional, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class SaveUserAddressDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "province", example: "34.406485" }) + lat: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "province", example: "49.159527" }) + lon: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "province", example: 25 }) + provinceId: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "city", example: 569 }) + cityId: number; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.PostalIncorrect }) + @Length(10, 10, { message: CommonMessage.PostalIncorrect }) + @ApiProperty({ type: "string", description: "iran postal code format (10 char)", example: "5869568592" }) + postalCode: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "address of user ", example: "خ ملک" }) + address: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "plaque ", example: "25" }) + plaque: string; +} + +export class SaveSellerAddressDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "province", example: "34.406485" }) + lat: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "province", example: "49.159527" }) + lon: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "province", example: 25 }) + provinceId: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "city", example: 569 }) + cityId: number; + + // @Expose() + // @IsNotEmpty() + // @IsNumberString({ no_symbols: true }, { message: CommonMessage.PostalIncorrect }) + // @Length(10, 10, { message: CommonMessage.PostalIncorrect }) + // @ApiProperty({ type: "string", description: "iran postal code format (10 char)", example: "5869568592" }) + // postalCode: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "address of user ", example: "خ ملک" }) + address: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "plaque ", example: "25" }) + plaque: string; +} diff --git a/src/modules/address/address.controller.ts b/src/modules/address/address.controller.ts new file mode 100644 index 0000000..b3d7ba7 --- /dev/null +++ b/src/modules/address/address.controller.ts @@ -0,0 +1,54 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody } from "inversify-express-utils"; + +import { AddressService } from "./address.service"; +import { HttpStatus } from "../../common"; +import { SaveSellerAddressDTO, SaveUserAddressDTO } from "./DTO/userAddress.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/address") +@ApiTags("Address") +class AddressController extends BaseController { + @inject(IOCTYPES.AddressService) addressService: AddressService; + + @ApiOperation("get user location with geo coordinate") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("lat", "latitude", true) + @ApiQuery("lon", "longitude", true) + @httpGet("/location/reverse") + public async getLocationWithGeo(@queryParam("lat") lat: string, @queryParam("lon") lon: string) { + const data = await this.addressService.getLocationWithGeo({ lat, lon }); + return this.response(data); + } + + @ApiOperation("set user address of current session user ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(SaveUserAddressDTO) + @ApiAuth() + @httpPost("/user/save", Guard.authUser(), ValidationMiddleware.validateInput(SaveUserAddressDTO)) + public async saveUserAddress(@requestBody() addressDto: SaveUserAddressDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.addressService.setUserAddress(user._id.toString(), addressDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("set shop address of current session seller ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(SaveSellerAddressDTO) + @ApiAuth() + @httpPost("/seller/save", Guard.authSeller(), ValidationMiddleware.validateInput(SaveSellerAddressDTO)) + public async saveSellerShopAddress(@requestBody() addressDto: SaveSellerAddressDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.addressService.setSellerShopAddress(seller._id.toString(), addressDto); + return this.response(data, HttpStatus.Created); + } +} + +export { AddressController }; diff --git a/src/modules/address/address.repository.ts b/src/modules/address/address.repository.ts new file mode 100644 index 0000000..afa27bf --- /dev/null +++ b/src/modules/address/address.repository.ts @@ -0,0 +1,35 @@ +import { IAddress } from "./models/Abstraction/IAddress"; +import { AddressModel } from "./models/address.model"; +import { CityModel, ICity } from "./models/city.model"; +import { IProvince, ProvinceModel } from "./models/province.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class AddressRepo extends BaseRepository { + constructor() { + super(AddressModel); + } +} + +export class CityRepo extends BaseRepository { + constructor() { + super(CityModel); + } +} + +export class ProvinceRepo extends BaseRepository { + constructor() { + super(ProvinceModel); + } +} + +export function createAddressRepo(): AddressRepo { + return new AddressRepo(); +} + +export function createCityRepo(): CityRepo { + return new CityRepo(); +} + +export function createProvinceRepo(): ProvinceRepo { + return new ProvinceRepo(); +} diff --git a/src/modules/address/address.service.ts b/src/modules/address/address.service.ts new file mode 100644 index 0000000..f9caee7 --- /dev/null +++ b/src/modules/address/address.service.ts @@ -0,0 +1,179 @@ +import axios from "axios"; +import { inject, injectable } from "inversify"; +import { startSession } from "mongoose"; + +import { AddressRepo, CityRepo, ProvinceRepo } from "./address.repository"; +import { SaveSellerAddressDTO, SaveUserAddressDTO } from "./DTO/userAddress.dto"; +import { INominatimResponse } from "./interfaces/ILocation"; +import { AddressMessage, CommonMessage, ShopMessage, UserMessage } from "../../common/enums/message.enum"; +import { BadRequestError, InternalError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UserRepository } from "../user/user.repository"; +import { ICity } from "./models/city.model"; +import { IProvince } from "./models/province.model"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; + +@injectable() +class AddressService { + private NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"; + // https://nominatim.openstreetmap.org/reverse?lat=34.198770&lon=49.663600&format=json&accept-language=fa + // private MAP_IR_URL = "https://map.ir/reverse"; + // private MAP_IR_API_KEY = process.env.MAP_API_KEY; + // private NEHSNAN_URL = "https://api.neshan.org/v5/reverse"; + // private NESHAN_API_KEY = process.env.NESHAN_API_KEY; + @inject(IOCTYPES.UserRepository) private userRepository: UserRepository; + @inject(IOCTYPES.AddressRepo) addressRepo: AddressRepo; + @inject(IOCTYPES.CityRepo) private cityRepo: CityRepo; + @inject(IOCTYPES.ProvinceRepo) private provinceRepo: ProvinceRepo; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + + //############################################################# + //############################################################# + async getLocationWithGeo(geo: { lat: string; lon: string }) { + const location = await this.getAddressFromCoordinates(geo.lat, geo.lon); + let province: IProvince | null; + let city: ICity | null; + + //TODO:fix this + const provinceName = + location?.address?.province?.replace(/^استان\s*/, "").trim() ?? location?.address?.state?.replace(/^استان\s*/, "").trim(); + const cityName = location.address?.town ? location.address?.town?.replace(/^شهر\s*/, "").trim() : location?.address?.city; + + if (!provinceName || !cityName) throw new BadRequestError(AddressMessage.InvalidLocation); + + province = await this.provinceRepo.model.findOne({ name: provinceName }); + if (!province) province = await this.provinceRepo.model.create({ name: provinceName }); + + city = await this.cityRepo.model.findOne({ name: cityName }); + if (!city) city = await this.cityRepo.model.create({ name: cityName, province: province._id }); + + const displayNameArray = location.display_name.split(",").map((item) => item.trim()); + + const filteredDisplayNameArray = location.address.postcode + ? displayNameArray.filter((item) => !item.includes(location.address.postcode as string)) + : displayNameArray; + + const address = filteredDisplayNameArray.reverse().join(",").trim(); + + return { + province: provinceName, + provinceId: province._id, + city: city.name, + cityId: city._id, + address, + region: location.address.suburb, + }; + } + //############################################################# + //############################################################# + + async getUserAddress(addressId: string) { + const userAddress = await this.addressRepo.findById(addressId); + if (!userAddress) throw new BadRequestError([AddressMessage.UserShouldHaveAddress, AddressMessage.NotFound]); + return userAddress; + } + + //############################################################# + //############################################################# + + async setUserAddress(userId: string, addressDto: SaveUserAddressDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const address = await this.addressRepo.model.create( + [ + { + address: addressDto.address, + city: addressDto.cityId, + province: addressDto.provinceId, + plaque: addressDto.plaque, + postalCode: addressDto.postalCode, + lat: addressDto.lat, + lon: addressDto.lon, + }, + ], + { session }, + ); + + const updatedUser = await this.userRepository.model.findByIdAndUpdate(userId, { address: address[0]._id }, { session }); + if (!updatedUser) throw new BadRequestError(CommonMessage.NotValid); + + await session.commitTransaction(); + + return { + message: UserMessage.UserUpdated, + address: address[0], + }; + // + } catch (error) { + await session.abortTransaction(); + throw error; + // + } finally { + await session.endSession(); + } + } + + // + async setSellerShopAddress(sellerId: string, addressDto: SaveSellerAddressDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }).session(session); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + const address = await this.addressRepo.model.create( + [ + { + address: addressDto.address, + city: addressDto.cityId, + province: addressDto.provinceId, + plaque: addressDto.plaque, + // postalCode: addressDto.postalCode, + lat: addressDto.lat, + lon: addressDto.lon, + }, + ], + { session }, + ); + shop.address = address[0]._id; + await shop.save({ session }); + + await session.commitTransaction(); + + return { + message: AddressMessage.Updated, + address: address[0], + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //helpers + //########################## + private async getAddressFromCoordinates(lat: string, lon: string) { + try { + const response = await axios.get(this.NOMINATIM_URL, { + params: { lat, lon, format: "json", "accept-language": "fa" }, + headers: { + "User-Agent": "Mozilla/5.0", + }, + }); + + const location = response.data as INominatimResponse; + + if (!location) throw new BadRequestError("Address not found for the given coordinates."); + + return location; + } catch (error) { + console.log(error); + throw new InternalError([`Failed to get address from coordinates: ${error}`]); + } + } +} + +export { AddressService }; diff --git a/src/modules/address/interfaces/ILocation.ts b/src/modules/address/interfaces/ILocation.ts new file mode 100644 index 0000000..289f5dc --- /dev/null +++ b/src/modules/address/interfaces/ILocation.ts @@ -0,0 +1,75 @@ +export interface IMapIrResponse { + address: string; + postal_address: string; + address_compact: string; + primary: string; + name: string; + poi: string; + penult: string; + country: string; + province: string; + county: string; + district: string; + rural_district: string; + city: string; + village: string; + region: string; + neighbourhood: string; + last: string; + plaque: string; + postal_code: string; + geom: { + type: string; + coordinates: string[]; + }; +} + +export interface INeshanResponse { + status: string; + formatted_address: string; + route_name: string; + route_type: string; + neighbourhood: string; + city: string; + state: string; + place: null; + municipality_zone: string; + in_traffic_zone: string; + in_odd_even_zone: string; + village: string; + county: string; + district: string; +} + +export interface INominatimResponse { + place_id: number; + licence: string; + osm_type: string; + osm_id: number; + lat: string; + lon: string; + class: string; + type: string; + place_rank: number; + importance: number; + addresstype: string; + name: string; + display_name: string; + address: { + road?: string; + province?: string; + village?: string; + neighbourhood?: string; + suburb?: string; + town?: string; + city: string; + district: string; + county: string; + state: string; + "ISO3166-2-lvl4": string; + postcode?: string; + country: string; + country_code: string; + }; + boundingbox: string[]; +} diff --git a/src/modules/address/models/Abstraction/IAddress.ts b/src/modules/address/models/Abstraction/IAddress.ts new file mode 100644 index 0000000..377650d --- /dev/null +++ b/src/modules/address/models/Abstraction/IAddress.ts @@ -0,0 +1,9 @@ +export interface IAddress { + address: string; + city: number; + province: number; + postalCode: string; + plaque: string; + lat: string; + lon: string; +} diff --git a/src/modules/address/models/address.model.ts b/src/modules/address/models/address.model.ts new file mode 100644 index 0000000..59a2e1e --- /dev/null +++ b/src/modules/address/models/address.model.ts @@ -0,0 +1,24 @@ +import { Schema, model } from "mongoose"; + +import { IAddress } from "./Abstraction/IAddress"; + +const AddressSchema = new Schema( + { + address: { type: String, required: true }, + city: { type: Number, ref: "City", required: true }, + province: { type: Number, ref: "Province", required: true }, + postalCode: { type: String, default: null }, + plaque: { type: String, default: null }, + lat: { type: String, default: null }, + lon: { type: String, default: null }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +AddressSchema.pre(["find", "findOne"], function (next) { + this.populate([{ path: "city" }, { path: "province" }]); + next(); +}); +const AddressModel = model("Address", AddressSchema); + +export { AddressModel }; diff --git a/src/modules/address/models/city.model.ts b/src/modules/address/models/city.model.ts new file mode 100644 index 0000000..ba1d38e --- /dev/null +++ b/src/modules/address/models/city.model.ts @@ -0,0 +1,24 @@ +import mongoose, { Schema, model } from "mongoose"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +export interface ICity { + _id: number; + name: string; + province: number; +} + +const citySchema = new Schema( + { + _id: Number, + name: { type: String, required: true, index: true }, + province: { type: Number, ref: "Province", required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, _id: false, id: false }, +); + +citySchema.plugin(AutoIncrement, { id: "city_id", inc_field: "_id" }); +const CityModel = model("City", citySchema); + +export { CityModel }; diff --git a/src/modules/address/models/province.model.ts b/src/modules/address/models/province.model.ts new file mode 100644 index 0000000..5db1896 --- /dev/null +++ b/src/modules/address/models/province.model.ts @@ -0,0 +1,22 @@ +import mongoose, { Schema, model } from "mongoose"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +export interface IProvince { + _id: number; + name: string; +} + +const ProvinceSchema = new Schema( + { + _id: Number, + name: { type: String, required: true, unique: true, index: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, _id: false, id: false }, +); + +ProvinceSchema.plugin(AutoIncrement, { id: "province_id", inc_field: "_id" }); +const ProvinceModel = model("Province", ProvinceSchema); + +export { ProvinceModel }; diff --git a/src/modules/admin/DTO/changePassword.dto.ts b/src/modules/admin/DTO/changePassword.dto.ts new file mode 100644 index 0000000..ae25ae7 --- /dev/null +++ b/src/modules/admin/DTO/changePassword.dto.ts @@ -0,0 +1,23 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../../common/enums/message.enum"; + +export class ChangePasswordDTO { + @Expose() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email of created admin", example: "defaultAdmin@email.com" }) + email: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.PasswordNotEmpty }) + @ApiProperty({ type: "string", description: "password of created admin", example: "123@123!" }) + password: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.PasswordNotEmpty }) + @ApiProperty({ type: "string", description: "new password", example: "123@123!" }) + newPassword: string; +} diff --git a/src/modules/admin/DTO/contract.dto.ts b/src/modules/admin/DTO/contract.dto.ts new file mode 100644 index 0000000..455179c --- /dev/null +++ b/src/modules/admin/DTO/contract.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateContractDTO { + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "content of the contract", example: "content of the contract" }) + content: string; +} + +export class UpdateContractDTO { + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "content of the contract", example: "content of the contract" }) + content: string; +} diff --git a/src/modules/admin/DTO/createAdmin.dto.ts b/src/modules/admin/DTO/createAdmin.dto.ts new file mode 100644 index 0000000..e97faed --- /dev/null +++ b/src/modules/admin/DTO/createAdmin.dto.ts @@ -0,0 +1,37 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AdminMessage, RoleMessage } from "../../../common/enums/message.enum"; + +export class CreateAdminDTO { + @Expose() + @IsNotEmpty({ message: AdminMessage.FullNameNotEmpty }) + @ApiProperty({ type: "string", description: "full name of new admin", example: "admin 1" }) + fullName: string; + + @Expose() + @IsNotEmpty({ message: AdminMessage.UserNameNotEmpty }) + @ApiProperty({ type: "string", description: "username of new admin", example: "admin1" }) + userName: string; + + @Expose() + @IsNotEmpty({ message: AdminMessage.EmailNotEmpty }) + @ApiProperty({ type: "string", description: "email of new admin", example: "admin1@gmail.com" }) + email: string; + + @Expose() + @IsNotEmpty({ message: AdminMessage.PhoneNotEmpty }) + @ApiProperty({ type: "string", description: "phoneNumber of new admin", example: "09918914108" }) + phoneNumber: string; + + @Expose() + @IsNotEmpty({ message: RoleMessage.PermissionsNotEmpty }) + @ApiProperty({ type: "array", description: "permissions of user", example: ["67723b0c8109df157bd9b2c0", "67723b0c8109df157bd9b2ba"] }) + permissions: string[]; + + @Expose() + @IsNotEmpty({ message: AdminMessage.PasswordNotEmpty }) + @ApiProperty({ type: "string", description: "password", example: "123@123!" }) + password: string; +} diff --git a/src/modules/admin/DTO/createRole.dto.ts b/src/modules/admin/DTO/createRole.dto.ts new file mode 100644 index 0000000..707a4ff --- /dev/null +++ b/src/modules/admin/DTO/createRole.dto.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { RoleMessage } from "../../../common/enums/message.enum"; + +export class CreateRoleDTO { + @Expose() + @IsNotEmpty({ message: RoleMessage.RoleNameNotEmpty }) + @ApiProperty({ type: "string", description: "name of role", example: "ADMIN" }) + name: string; + + @Expose() + @IsNotEmpty({ message: RoleMessage.PermissionsNotEmpty }) + @ApiProperty({ type: "array", description: "permissions of role", example: ["67723b0c8109df157bd9b2c0", "67723b0c8109df157bd9b2ba"] }) + permissions: string[]; +} diff --git a/src/modules/admin/DTO/loginPassword.dto.ts b/src/modules/admin/DTO/loginPassword.dto.ts new file mode 100644 index 0000000..c19e7f6 --- /dev/null +++ b/src/modules/admin/DTO/loginPassword.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../../common/enums/message.enum"; + +export class LoginPassDTO { + @Expose() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email of created admin", example: "defaultAdmin@email.com" }) + email: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.PasswordNotEmpty }) + @ApiProperty({ type: "string", description: "password of created admin", example: "123@123!" }) + password: string; +} diff --git a/src/modules/admin/DTO/media.dto.ts b/src/modules/admin/DTO/media.dto.ts new file mode 100644 index 0000000..8db344d --- /dev/null +++ b/src/modules/admin/DTO/media.dto.ts @@ -0,0 +1,205 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { DisplayLocations } from "../../../common/enums/media.enum"; + +//***************************************** +//***************************************** +export class CreateSliderDTO { + @Expose() + @IsNotEmpty() + @IsEnum(DisplayLocations) + @ApiProperty({ + type: "string", + description: "Display location of the slider", + example: `${DisplayLocations.Desktop} | ${DisplayLocations.Mobile}`, + }) + displayLocation: DisplayLocations; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the image url of slider", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the alt text of image ", example: "slider-image" }) + altText: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title text of image ", example: "slider 1" }) + title: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the link of that slider that is clickable", example: "/summer-sale" }) + linkUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "slider active status", example: true, required: false }) + isActive: boolean; +} + +export class CreateBannerDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the image url of slider", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the alt text of image ", example: "slider-image" }) + altText: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title text of image ", example: "slider 1" }) + title: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the link of that slider that is clickable", example: "/summer-sale" }) + linkUrl: string; + + // @Expose() + // @IsNotEmpty() + // @IsEnum(DisplayLocations) + // @ApiProperty({ + // type: "string", + // description: "Display location of the slider", + // example: "homepage_top | homepage_bottom | category_page | product_page", + // }) + // displayLocation: DisplayLocations; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "slider active status", example: true, required: false }) + isActive: boolean; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the order of image for the banner group", example: 1, required: false }) + order: number; +} +//***************************************** +//***************************************** + +export class UpdateSliderDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the image url of slider", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the alt text of image ", example: "slider-image" }) + altText: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title text of image ", example: "slider 1" }) + title: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the link of that slider that is clickable", example: "/summer-sale" }) + linkUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "slider active status", example: true, required: false }) + isActive: boolean; +} +export class UpdateBannerDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the image url of slider", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the alt text of image ", example: "slider-image" }) + altText: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title text of image ", example: "slider 1" }) + title: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the link of that slider that is clickable", example: "/summer-sale" }) + linkUrl: string; + + // @Expose() + // @IsOptional() + // @IsNotEmpty() + // @IsEnum(DisplayLocations) + // @ApiProperty({ + // type: "string", + // description: "Display location of the slider", + // example: "homepage_top | homepage_bottom | category_page | product_page", + // }) + // displayLocation: DisplayLocations; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "slider active status", example: true, required: false }) + isActive: boolean; + + // @Expose() + // @IsOptional() + // @IsNotEmpty() + // @ApiProperty({ type: "number", description: "the order of image for the banner group", example: 1, required: false }) + // order: number; +} +//***************************************** +//***************************************** +export class ActivateMediaDTO { + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "Active status of the media", example: true }) + isActive: boolean; +} diff --git a/src/modules/admin/DTO/product-param.dto.ts b/src/modules/admin/DTO/product-param.dto.ts new file mode 100644 index 0000000..90c021d --- /dev/null +++ b/src/modules/admin/DTO/product-param.dto.ts @@ -0,0 +1,30 @@ +import { Expose } from "class-transformer"; +import { IsInt, IsMongoId, IsNotEmpty } from "class-validator"; + +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CommonMessage } from "../../../common/enums/message.enum"; +import { IncredibleOffersModel } from "../../product/models/IncredibleOffers.model"; +import { ProductModel } from "../../product/models/product.model"; +import { ProductVariantModel } from "../../product/models/productVariant.model"; + +export class AddIncredibleOffersParamDto { + @Expose() + @IsNotEmpty() + @IsInt() + @IsValidId(ProductModel) + id: number; + + @Expose() + @IsNotEmpty() + @IsMongoId({ message: CommonMessage.NotValidId }) + @IsValidId(ProductVariantModel) + variantId: string; +} + +export class DeleteIncredibleOffersParamDto { + @Expose() + @IsNotEmpty() + @IsMongoId({ message: CommonMessage.NotValidId }) + @IsValidId(IncredibleOffersModel) + id: string; +} diff --git a/src/modules/admin/DTO/updateSellerDocument.dto.ts b/src/modules/admin/DTO/updateSellerDocument.dto.ts new file mode 100644 index 0000000..26c632a --- /dev/null +++ b/src/modules/admin/DTO/updateSellerDocument.dto.ts @@ -0,0 +1,29 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { StatusEnum } from "../../../common/enums/status.enum"; +import { SellerDocumentModel } from "../../seller/models/sellerDocument.model"; + +export class UpdateSellerDocumentDto { + @Expose() + @IsNotEmpty() + @IsValidId(SellerDocumentModel) + @ApiProperty({ type: "string", description: "the document id of seller", example: "60f4b3b3b3b3b3b3b3b3b3b3" }) + docsId: string; + + @Expose() + @IsNotEmpty() + @IsEnum(StatusEnum) + @ApiProperty({ type: "string", description: `${StatusEnum.Approved} | ${StatusEnum.Rejected}`, example: StatusEnum.Approved }) + status: StatusEnum; +} + +export class RejectReasonSellerDocumentDto { + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the reason of rejection", example: "the document is not clear" }) + reason: string; +} diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..b5e690a --- /dev/null +++ b/src/modules/admin/admin.service.ts @@ -0,0 +1,234 @@ +import { compare, hash } from "bcrypt"; +import { inject, injectable } from "inversify"; + +import { LoginPassDTO } from "./DTO/loginPassword.dto"; +import { AdminMessage, AuthMessage, CommonMessage, ContractMessage, RoleMessage, ShopMessage } from "../../common/enums/message.enum"; +import { TokenService } from "../token/token.service"; +import { ChangePasswordDTO } from "./DTO/changePassword.dto"; +import { CreateContractDTO, UpdateContractDTO } from "./DTO/contract.dto"; +import { CreateAdminDTO } from "./DTO/createAdmin.dto"; +import { CreateRoleDTO } from "./DTO/createRole.dto"; +import { AdminRepository } from "./repository/admin"; +import { ContractRepository } from "./repository/contract"; +import { BannerRepository, SliderRepository } from "./repository/media"; +import { PermissionRepository } from "./repository/permission"; +import { RoleRepository } from "./repository/role"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { BrandService } from "../brand/brand.service"; +import { OrderService } from "../order/order.service"; +import { ProductService } from "../product/providers/product.service"; +import { SellerService } from "../seller/seller.service"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; +import { UserService } from "../user/user.service"; +import { WalletService } from "../wallet/wallet.service"; +import { WarrantyService } from "../warranty/warranty.service"; +import { RoleEnum } from "./models/Abstraction/IRole"; + +@injectable() +class AdminService { + @inject(IOCTYPES.AdminRepository) adminRepo: AdminRepository; + @inject(IOCTYPES.RoleRepository) roleRepo: RoleRepository; + @inject(IOCTYPES.PermissionRepository) permissionRepo: PermissionRepository; + @inject(IOCTYPES.TokenService) tokenService: TokenService; + @inject(IOCTYPES.SliderRepository) sliderRepo: SliderRepository; + @inject(IOCTYPES.BannerRepository) bannerRepo: BannerRepository; + @inject(IOCTYPES.ContractRepository) contractRepo: ContractRepository; + @inject(IOCTYPES.OrderService) private orderService: OrderService; + @inject(IOCTYPES.WalletService) private walletService: WalletService; + @inject(IOCTYPES.ProductService) private productService: ProductService; + @inject(IOCTYPES.SellerService) private sellerService: SellerService; + @inject(IOCTYPES.UserService) private userService: UserService; + @inject(IOCTYPES.BrandService) private brandService: BrandService; + @inject(IOCTYPES.WarrantyService) private warrantyService: WarrantyService; + @inject(IOCTYPES.ShopRepo) private shopRepo: ShopRepo; + + async loginPasswordS(loginDto: LoginPassDTO) { + const { password, email } = loginDto; + + const admin = await this.adminRepo.findByEmail(email); + if (!admin) throw new BadRequestError(AuthMessage.EmailOrPasswordInc); + console.log(admin); + const passMatch = await this.comparePassword(password, admin.password); + if (!passMatch) throw new BadRequestError(AuthMessage.EmailOrPasswordInc); + + const tokens = await this.tokenService.generateAuthTokens({ sub: admin._id.toString() }); + + return { + message: AuthMessage.SuccessLogin, + ...tokens, + }; + } + + async profileS(adminId: string) { + const admin = await this.adminRepo.model.findOne({ _id: adminId }, { password: 0, role: 0 }); + if (!admin) throw new BadRequestError(CommonMessage.NotFound); + return { admin }; + } + + async changePasswordS(changePasswordDto: ChangePasswordDTO) { + const { email, password, newPassword } = changePasswordDto; + + const admin = await this.adminRepo.findByEmail(email); + if (!admin) throw new BadRequestError(AuthMessage.EmailOrPasswordInc); + const passMatch = await this.comparePassword(password, admin.password); + if (!passMatch) throw new BadRequestError(AuthMessage.EmailOrPasswordInc); + const newChangedPassword = await hash(newPassword, 10); + const updatedAdmin = await this.adminRepo.model.findByIdAndUpdate(admin._id.toString(), { password: newChangedPassword }); + console.log(updatedAdmin); + return { + message: AuthMessage.PasswordChangedSuccessfully, + }; + } + + async getContactContent() { + const contract = await this.contractRepo.model.findOne(); + return { contract }; + } + + async createContentS(createDto: CreateContractDTO) { + const contract = await this.contractRepo.model.findOne(); + if (contract) { + throw new BadRequestError(ContractMessage.AlreadyExist); + } + const createdContent = await this.contractRepo.model.create({ + ...createDto, + }); + return { createdContent }; + } + + async updateContentS(updateDto: UpdateContractDTO) { + const contract = await this.contractRepo.model.findOne(); + if (!contract) { + throw new BadRequestError(ContractMessage.NotFound); + } + const updatedContent = await this.contractRepo.model.findByIdAndUpdate( + contract._id.toString(), + { + ...updateDto, + }, + { new: true }, + ); + return { updatedContent }; + } + + async comparePassword(raw: string, hashed: string) { + return await compare(raw, hashed); + } + + async createAdminS(adminId: string, createDto: CreateAdminDTO) { + const { password, userName, fullName, email, phoneNumber, permissions } = createDto; + const admin = await this.adminRepo.findAdmin(email, phoneNumber); + if (admin) throw new BadRequestError(AdminMessage.AdminIsExist); + + const roleDoc = await this.roleRepo.model.findOne({ name: RoleEnum.SUPERADMIN }); + if (!roleDoc) throw new BadRequestError(AdminMessage.RoleNotFound); + + const permissionDocs = await this.permissionRepo.model.find({ _id: { $in: permissions } }); + + const createdAdmin = await this.adminRepo.model.create({ + userName, + fullName, + phoneNumber, + email, + password, + permissions: permissionDocs.map((perm) => perm._id), + role: roleDoc._id, + createdBy: adminId, + }); + const { password: adminPassword, ...newAdmin } = createdAdmin.toObject(); + return { newAdmin }; + } + + async getAdminsS() { + const admins = await this.adminRepo.getAdmins(); + return { admins }; + } + + async deleteAdminS(adminId: string) { + const admin = await this.adminRepo.model.findByIdAndDelete(adminId); + if (!admin) throw new BadRequestError(AdminMessage.NotFound); + return { message: AdminMessage.DeletedSuccessfully }; + } + + async createRoleS(createDto: CreateRoleDTO) { + const { name, permissions } = createDto; + const roleExist = await this.roleRepo.model.findOne({ name }); + if (roleExist) throw new BadRequestError(RoleMessage.RoleExist); + const permissionDocs = await this.permissionRepo.model.find({ _id: { $in: permissions } }); + const role = await this.roleRepo.model.create({ name, permissions: permissionDocs.map((perm) => perm._id) }); + return { role }; + } + + async getRoles() { + const roles = await this.roleRepo.model.find({}); + return { roles }; + } + + async getPermissions() { + const permissions = await this.permissionRepo.model.find(); + return { permissions }; + } + + async reports() { + const dailySales = await this.orderService.getDailySalesReport(); + const weeklySales = await this.orderService.getWeeklySalesReport(); + const monthlySales = await this.orderService.getMonthlySalesReport(); + const yearlySales = await this.orderService.getAnnualSalesReport(); + const dailyShipmentSales = await this.orderService.getDailyShipmentSalesReport(); + const weeklyShipmentSales = await this.orderService.getWeeklyShipmentSalesReport(); + const monthlyShipmentSales = await this.orderService.getMonthlyShipmentSalesReport(); + const yearlyShipmentSales = await this.orderService.getAnnualShipmentSalesReport(); + const FiveDaysAgoSales = await this.orderService.getFiveDaysAgoSalesReport(); + const topProducts = await this.orderService.getTopSellingProductsS(); + const proccessingOrders = await this.orderService.getAllProcessingOrders(); + const withdrawalRequests = await this.walletService.getWithdrawalRequestsReport(); + const pendingComments = await this.productService.getCommentsForReport(); + const pendingQuestions = await this.productService.getQuestionsForReport(); + const sellersCount = await this.sellerService.getAllSellerForReport(); + const usersCount = await this.userService.getUsersCount(); + const wholesaleRequestCount = await this.sellerService.getWholesaleRequestCount(); + const pendingBrandsCount = await this.brandService.getPendingBrandsCount(); + const pendingWarranties = await this.warrantyService.getPendingWarranties(); + const productCount = await this.productService.getProductCount(); + const pendingProductCount = await this.productService.getPendingProductCount(); + return { + dailySales, + weeklySales, + monthlySales, + yearlySales, + dailyShipmentSales, + weeklyShipmentSales, + monthlyShipmentSales, + yearlyShipmentSales, + withdrawalRequests, + FiveDaysAgoSales, + topProducts, + proccessingOrders, + pendingComments, + pendingQuestions, + sellersCount, + usersCount, + wholesaleRequestCount, + pendingBrandsCount, + pendingWarranties, + productCount, + pendingProductCount, + }; + } + + async changeShopChatStatus(sellerId: string) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.ADMIN }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + shop.isChatActive = !shop.isChatActive; + await shop.save(); + + return { + message: CommonMessage.Updated, + }; + } +} + +export { AdminService }; diff --git a/src/modules/admin/controllers/admin.controller.ts b/src/modules/admin/controllers/admin.controller.ts new file mode 100644 index 0000000..1bbb9b7 --- /dev/null +++ b/src/modules/admin/controllers/admin.controller.ts @@ -0,0 +1,132 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { appConfig } from "../../../core/config/app.config"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { ShopUpdateDTO } from "../../shop/DTO/shop-update.dto"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { ShopService } from "../../shop/shop.service"; +import { AdminService } from "../admin.service"; +import { ChangePasswordDTO } from "../DTO/changePassword.dto"; +import { CreateAdminDTO } from "../DTO/createAdmin.dto"; +import { LoginPassDTO } from "../DTO/loginPassword.dto"; +import { IAdmin } from "../models/Abstraction/IAdmin"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin base") +@controller("/admin") +export class AdminBaseController extends BaseController { + @inject(IOCTYPES.AdminService) private adminService: AdminService; + @inject(IOCTYPES.ShopService) private shopService: ShopService; + + @ApiOperation("login admin with its password") + @ApiResponse("login the admin", HttpStatus.Ok) + @ApiModel(LoginPassDTO) + @httpPost("/login/password", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(LoginPassDTO)) + public async loginPassword(@requestBody() LoginPasswordDto: LoginPassDTO) { + const data = await this.adminService.loginPasswordS(LoginPasswordDto); + return this.response(data); + } + + @ApiOperation("change admin password") + @ApiResponse("change admin password", HttpStatus.Ok) + @ApiModel(ChangePasswordDTO) + @ApiAuth() + @httpPatch("/change/password", rateLimit(appConfig.rate), Guard.authAdmin(), ValidationMiddleware.validateInput(ChangePasswordDTO)) + public async changePassword(@requestBody() changePasswordDTO: ChangePasswordDTO) { + const data = await this.adminService.changePasswordS(changePasswordDTO); + return this.response(data); + } + + @ApiOperation("get admin profile") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/profile", Guard.authAdmin()) + public async profile(@request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.adminService.profileS(admin._id.toString()); + return this.response(data); + } + + @ApiOperation("get native shop info") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/shop", Guard.authAdmin()) + public async getShop(@request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.shopService.getShopInfo(admin._id.toString(), OwnerRef.ADMIN); + return this.response(data); + } + + @ApiOperation("update native shop info") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiModel(ShopUpdateDTO) + @ApiAuth() + @httpPatch("/shop", Guard.authAdmin(), ValidationMiddleware.validateInput(ShopUpdateDTO)) + public async updateShop(@request() req: Request, @requestBody() updateDto: ShopUpdateDTO) { + const admin = req.user as IAdmin; + const data = await this.shopService.updateSellerShopInfo(admin._id.toString(), updateDto); + return this.response(data); + } + + @ApiOperation("get permissions") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/permissions", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getPermissions() { + const data = await this.adminService.getPermissions(); + return this.response(data); + } + + @ApiOperation("Create new admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateAdminDTO) + @ApiAuth() + @httpPost( + "/create", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateAdminDTO), + ) + public async createAdmin(@request() req: Request, @requestBody() createDto: CreateAdminDTO) { + const admin = req.user as IAdmin; + const data = await this.adminService.createAdminS(admin._id.toString(), createDto); + return this.response(data); + } + + @ApiOperation("delete admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @ApiParam("id", "admin id", true) + @httpDelete("/:id/delete", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteAdmin(@requestParam("id") adminId: string) { + const data = await this.adminService.deleteAdminS(adminId); + return this.response(data); + } + + @ApiOperation("get admins") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/admins", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getAdmins() { + const data = await this.adminService.getAdminsS(); + return this.response(data); + } + + //order-reports + @ApiOperation("get all sellers products") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/reports", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getOrdersReports() { + const data = await this.adminService.reports(); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/blog.controller.ts b/src/modules/admin/controllers/blog.controller.ts new file mode 100644 index 0000000..5e872c5 --- /dev/null +++ b/src/modules/admin/controllers/blog.controller.ts @@ -0,0 +1,121 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpDelete, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { UploadService } from "../../../utils/upload.service"; +import { BlogService } from "../../blog/blog.service"; +import { CreateBlogDTO, UpdateBlogDTO } from "../../blog/DTO/createBlog.dto"; +import { CreateBlogCategoryDTO, UpdateBlogCategoryDTO } from "../../blog/DTO/createBlogCategory.dto"; +import { IAdmin } from "../models/Abstraction/IAdmin"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Blog") +@controller("/admin/blog") +export class AdminBlogController extends BaseController { + @inject(IOCTYPES.BlogService) private blogService: BlogService; + + @ApiOperation("create a blog category") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateBlogCategoryDTO) + @ApiAuth() + @httpPost( + "/category", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.BLOG]), + ValidationMiddleware.validateInput(CreateBlogCategoryDTO), + ) + public async createBlogCategory(@requestBody() createBlogCatDto: CreateBlogCategoryDTO) { + const data = await this.blogService.createBlogCategory(createBlogCatDto); + return this.response(data, HttpStatus.Created); + } + //delete a blog category + @ApiOperation("delete a blog category with its id") + @ApiParam("categoryId", "the blog category id ", true) + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpDelete("/category/:categoryId", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.BLOG])) + public async deleteBlogCategory(@requestParam("categoryId") catId: string) { + const data = await this.blogService.deleteBlogCategory(catId); + return this.response(data); + } + + @ApiOperation("delete a blog with its id") + @ApiParam("blogId", "the blog id ", true) + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpDelete("/:blogId", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.BLOG])) + public async deleteBlog(@requestParam("blogId") blogId: string) { + const data = await this.blogService.deleteBlog(blogId); + return this.response(data); + } + + @ApiOperation("update a blog category") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "blog category id", true) + @ApiModel(UpdateBlogCategoryDTO) + @ApiAuth() + @httpPost( + "/category/:id", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.BLOG]), + ValidationMiddleware.validateInput(UpdateBlogCategoryDTO), + ) + public async updateBlogCategory(@requestParam("id") blogCatId: string, @requestBody() updateBlogCatDto: UpdateBlogCategoryDTO) { + const data = await this.blogService.updateBlogCategory(blogCatId, updateBlogCatDto); + return this.response(data); + } + + @ApiOperation("create a blog") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateBlogDTO) + @ApiAuth() + @httpPost("", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.BLOG]), ValidationMiddleware.validateInput(CreateBlogDTO)) + public async createBlog(@request() req: Request, @requestBody() createBlogDto: CreateBlogDTO) { + const admin = req.user as IAdmin; + const data = await this.blogService.createBlog(admin._id.toString(), createBlogDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a blog with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "blog id", true) + @ApiModel(UpdateBlogDTO) + @ApiAuth() + @httpPost( + "/:id", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.BLOG]), + ValidationMiddleware.validateInput(UpdateBlogDTO), + ) + public async updateBlog(@requestParam("id") blogId: string, @requestBody() updateBlogDto: UpdateBlogDTO) { + const data = await this.blogService.updateBlog(blogId, updateBlogDto); + return this.response(data); + } + + //blog uploader + @ApiOperation("Upload a single blog image ==> need to login as admin") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/upload/single", Guard.authAdmin(), UploadService.single("image", "blog-image")) + public async uploadBlogImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + }, + }; + return this.response(data, HttpStatus.Accepted); + } +} diff --git a/src/modules/admin/controllers/brand.controller.ts b/src/modules/admin/controllers/brand.controller.ts new file mode 100644 index 0000000..719a73a --- /dev/null +++ b/src/modules/admin/controllers/brand.controller.ts @@ -0,0 +1,89 @@ +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpDelete, httpPatch, httpPost, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { appConfig } from "../../../core/config/app.config"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { BrandService } from "../../brand/brand.service"; +import { CreateBrandDTO, CreateGlobalBrand } from "../../brand/DTO/createBrand.dto"; +import { UpdateBrandDTO } from "../../brand/DTO/updateBrand.dto"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Brand") +@controller("/admin/brand") +export class AdminBrandController extends BaseController { + @inject(IOCTYPES.BrandService) private brandService: BrandService; + + @ApiOperation("request to create a new brand ==> login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(CreateBrandDTO) + @ApiAuth() + @httpPost( + "", + rateLimit(appConfig.rate), + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateBrandDTO), + ) + public async createBrand(@requestBody() createBrandDto: CreateBrandDTO) { + const data = await this.brandService.createBrand(createBrandDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("request to create a new global brand ==> login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(CreateGlobalBrand) + @ApiAuth() + @httpPost( + "/global", + rateLimit(appConfig.rate), + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateGlobalBrand), + ) + public async createGlobalBrand(@requestBody() createBrandDto: CreateGlobalBrand) { + const data = await this.brandService.createGlobalBrand(createBrandDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("update a brand with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of warranty") + @ApiModel(UpdateBrandDTO) + @ApiAuth() + @httpPatch( + "/:id", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(UpdateBrandDTO), + ) + public async UpdateBrand(@requestParam("id") id: string, @requestBody() updateDto: UpdateBrandDTO) { + const data = await this.brandService.updateById(id, updateDto); + return this.response({ data }); + } + + @ApiOperation("approve a brand status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of brand") + @ApiAuth() + @httpPost("/:id/approve", Guard.authAdmin()) + public async approveBrand(@requestParam("id") id: string) { + const data = await this.brandService.approve(id); + return this.response({ data }); + } + + @ApiOperation("delete a brand with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of warranty") + @ApiAuth() + @httpDelete("/:id", Guard.authAdmin()) + public async deleteBrand(@requestParam("id") id: string) { + const data = await this.brandService.deleteById(id); + return this.response({ data }); + } +} diff --git a/src/modules/admin/controllers/category.controller.ts b/src/modules/admin/controllers/category.controller.ts new file mode 100644 index 0000000..ff890e2 --- /dev/null +++ b/src/modules/admin/controllers/category.controller.ts @@ -0,0 +1,125 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpPatch, httpPost, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiBody, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { ParamDto } from "../../../common/dto/param.dto"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CategoryService } from "../../category/category.service"; +import { CreateCategoryDTO } from "../../category/DTO/CreateCategory.dto"; +import { CreateCategoryAttDTO } from "../../category/DTO/createCategoryAtt.dto"; +import { CreateCategoryThemeDTO, UpdateCategoryVariantDTO } from "../../category/DTO/createCategoryTheme.dto"; +import { UpdateCategoryDTO } from "../../category/DTO/UpdateCategory.dto"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Category") +@controller("/admin/category") +export class AdminCategoryController extends BaseController { + @inject(IOCTYPES.CategoryService) private categoryService: CategoryService; + + @ApiOperation("create a category ") + @ApiResponse("return created category", HttpStatus.Created) + @ApiModel(CreateCategoryDTO) + @ApiAuth() + @httpPost("", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateCategoryDTO)) + public async createCategory(@requestBody() createDto: CreateCategoryDTO) { + const data = await this.categoryService.createCategory(createDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("update a category") + @ApiResponse("successful", HttpStatus.Created) + @ApiAuth() + @ApiModel(UpdateCategoryDTO) + @httpPatch( + "", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(UpdateCategoryDTO), + ) + public async updateCategory(@requestBody() updateDto: UpdateCategoryDTO) { + const data = await this.categoryService.updateCategory(updateDto); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("delete a category with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of category") + @ApiAuth() + @httpDelete("/:id/delete", Guard.authAdmin()) + public async deleteCategory(@requestParam("id") id: string) { + const data = await this.categoryService.deleteCategory(id); + return this.response(data); + } + + @ApiOperation("delete a category attribute with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of attribute") + @ApiAuth() + @httpDelete("/attributes/:id/delete", Guard.authAdmin()) + public async deleteCategoryAttribute(@requestParam("id") id: string) { + const data = await this.categoryService.deleteCategoryAttributeS(id); + return this.response(data); + } + + @ApiOperation("delete a category variant with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("catId", "id of category") + @ApiParam("varId", "id of variant") + @ApiAuth() + @httpDelete("/:catId/variant/:varId/delete", Guard.authAdmin()) + public async deleteCategoryVariant(@requestParam("catId") catId: string, @requestParam("varId") varId: string) { + const data = await this.categoryService.deleteCategoryVariantS(catId, varId); + return this.response(data); + } + + @ApiOperation("create a category theme and theme variant") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of category", true) + @ApiModel(CreateCategoryThemeDTO) + @ApiBody( + "the colors and sizes and meterage fields are optional and one of them should send and u can also send noColor_noSize too for no theme", + ) + @ApiAuth() + @httpPost( + "/:id/variants", + Guard.authAdmin(), + ValidationMiddleware.validateInput(CreateCategoryThemeDTO), + ValidationMiddleware.validateParameter(ParamDto), + ) + public async createCategoryTheme(@requestBody() createDto: CreateCategoryThemeDTO, @requestParam() paramDto: ParamDto) { + const data = await this.categoryService.createCategoryThemeS(createDto, paramDto.id); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("add a category variant") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of category", true) + @ApiModel(UpdateCategoryVariantDTO) + @ApiAuth() + @httpPatch( + "/:id/variants", + Guard.authAdmin(), + ValidationMiddleware.validateInput(UpdateCategoryVariantDTO), + ValidationMiddleware.validateParameter(ParamDto), + ) + public async updateCategoryVariant(@requestBody() updateDto: UpdateCategoryVariantDTO, @requestParam() paramDto: ParamDto) { + const data = await this.categoryService.updateCategoryVariantS(updateDto, paramDto.id); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("create a category attributes with its id") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of category", true) + @ApiModel(CreateCategoryAttDTO) + @ApiAuth() + @ApiBody("the attribute type can be text|select|number|checkbox|input ") + @httpPost("/:id/attributes", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateCategoryAttDTO)) + public async createCategoryAttribute(@requestBody() createAttributeDto: CreateCategoryAttDTO, @requestParam("id") catId: string) { + const data = await this.categoryService.createCategoryAttributeS(createAttributeDto, catId); + return this.response({ data }, HttpStatus.Created); + } +} diff --git a/src/modules/admin/controllers/coupon.controller.ts b/src/modules/admin/controllers/coupon.controller.ts new file mode 100644 index 0000000..1ceb133 --- /dev/null +++ b/src/modules/admin/controllers/coupon.controller.ts @@ -0,0 +1,100 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { ParamDto } from "../../../common/dto/param.dto"; +import { appConfig } from "../../../core/config/app.config"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CouponService } from "../../Coupon/coupon.service"; +import { CreateCouponDTO } from "../../Coupon/DTO/createCoupon.dto"; +import { UpdateCouponDTO } from "../../Coupon/DTO/updateCoupon.dto"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { IAdmin } from "../models/Abstraction/IAdmin"; + +@ApiTags("Admin Coupon") +@controller("/admin/coupon") +export class AdminCouponController extends BaseController { + @inject(IOCTYPES.CouponService) couponService: CouponService; + + @ApiOperation("get all native shop created coupon ") + @ApiResponse("successful") + @ApiAuth() + @httpGet("/native", Guard.authAdmin()) + public async getSellerCoupons(@request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.couponService.getSellerCoupons(admin._id.toString(), OwnerRef.ADMIN); + return this.response(data); + } + + @ApiOperation("get all seller shop created coupon ") + @ApiResponse("successful") + @ApiAuth() + @httpGet("/seller", Guard.authAdmin()) + public async getSellersCoupons() { + const data = await this.couponService.getSellersCoupons(); + return this.response(data); + } + + @ApiOperation("get a single coupon") + @ApiParam("id", "coupon id", true) + @ApiAuth() + @httpGet("/:id", Guard.authAdmin()) + public async getSingleCoupon(@requestParam() paramDto: ParamDto) { + const data = await this.couponService.getSingleCoupon(paramDto.id); + return this.response(data); + } + + @ApiOperation("create a Coupon") + @ApiResponse("successful") + @ApiModel(CreateCouponDTO) + @ApiAuth() + @httpPost("", rateLimit(appConfig.rate), Guard.authAdmin(), ValidationMiddleware.validateInput(CreateCouponDTO)) + public async createCoupon(@requestBody() createDto: CreateCouponDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.couponService.createCoupon(createDto, admin._id.toString(), OwnerRef.ADMIN); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a native shop coupon") + @ApiResponse("successful") + @ApiModel(UpdateCouponDTO) + @ApiParam("id", "coupon id", true) + @ApiAuth() + @httpPatch("/:id", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateCouponDTO)) + public async updateSellerCoupon( + @requestBody() updateDto: UpdateCouponDTO, + @request() req: Request, + @requestParam("id") couponId: string, + ) { + const admin = req.user as IAdmin; + const data = await this.couponService.updateCoupon(updateDto, admin._id.toString(), couponId, OwnerRef.ADMIN); + return this.response(data); + } + + @ApiOperation("deactivate a coupon ") + @ApiResponse("successful") + @ApiAuth() + @ApiParam("id", "coupon id", true) + @httpPatch("/:id/status", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async changeCouponStatus(@request() req: Request, @requestParam() paramDto: ParamDto) { + const admin = req.user as IAdmin; + const data = await this.couponService.changeCouponStatus(admin._id.toString(), paramDto.id, OwnerRef.ADMIN); + return this.response(data); + } + + @ApiOperation("delete a coupon") + @ApiResponse("successful") + @ApiAuth() + @ApiParam("id", "coupon id", true) + @httpDelete("/:id/delete", Guard.authAdmin()) + public async deleteCoupon(@requestParam("id") couponId: string) { + const data = await this.couponService.removeCouponByAdmin(couponId); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/financial.controller.ts b/src/modules/admin/controllers/financial.controller.ts new file mode 100644 index 0000000..34ae2f1 --- /dev/null +++ b/src/modules/admin/controllers/financial.controller.ts @@ -0,0 +1,149 @@ +import { Response } from "express"; +import { inject } from "inversify"; +import { + controller, + httpDelete, + httpGet, + httpPatch, + httpPost, + queryParam, + requestBody, + requestParam, + response, +} from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { ParamDto } from "../../../common/dto/param.dto"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { PaymentService } from "../../payment/providers/payment.service"; +import { CreatePricingDTO } from "../../pricing/DTO/createPricing.dto"; +import { UpdatePricingDTO } from "../../pricing/DTO/updatePricing.dto"; +import { PricingService } from "../../pricing/pricing.service"; +import { WithdrawalStatusDTO } from "../../wallet/DTO/withdraw.dto"; +import { WalletService } from "../../wallet/wallet.service"; + +@ApiTags("Admin Financial") +@controller("/admin/financial") +export class AdminFinancialController extends BaseController { + @inject(IOCTYPES.WalletService) private walletService: WalletService; + @inject(IOCTYPES.PaymentService) private paymentService: PaymentService; + @inject(IOCTYPES.PricingService) private pricingService: PricingService; + + //payments + @ApiOperation("get all payments") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("status", "status of payment ==> value = Completed | Cancelled | Pending ") + @ApiQuery( + "since", + "time of order created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiQuery("maxPrice", "search based on payment priceRange") + @ApiQuery("minPrice", "search based on payment priceRange") + @ApiAuth() + @httpGet("/payments", Guard.authAdmin()) + public async getPayments( + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("status") status: string, + @queryParam("since") since: string, + @queryParam("maxPrice") maxPrice: string, + @queryParam("minPrice") minPrice: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + status, + since: +since, + maxPrice: +maxPrice, + minPrice: +minPrice, + }; + const { ...data } = await this.paymentService.getAllPaymentsForAdminPanelS(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, payments: data.payments }); + } + + //wallet - withdrawal request + @ApiOperation("Get all seller withdrawal requests ==> login as admin") + @ApiResponse("Successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/withdrawal/requests", Guard.authAdmin()) + public async getAllWithdrawalRequests() { + const data = await this.walletService.getWithdrawalRequestsForAdminPanel(); + return this.response(data); + } + + @ApiOperation("Get withdrawal request CSV") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/withdrawal/requests/csv", Guard.authAdmin()) + public async getWithdrawalRequestCsv(@response() res: Response): Promise { + // Set the response headers for CSV + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", "attachment; filename=withdrawal_requests.csv"); + + // Call the service to generate the CSV stream and pipe it to the response + await this.walletService.getWithdrawalRequestsCsv(res); + } + + @ApiOperation("approve a withdrawal with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of withdrawal") + @ApiModel(WithdrawalStatusDTO) + @ApiAuth() + @httpPost("/withdrawal/:id/process", Guard.authAdmin()) + public async processWithdrawal(@requestParam("id") id: string, @requestBody() withdrawalStatusDto: WithdrawalStatusDTO) { + const data = await this.walletService.processWithdrawal(id, withdrawalStatusDto); + return this.response(data); + } + + @ApiOperation("get all pricing") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/pricing", Guard.authAdmin()) + public async getPricing() { + const data = await this.pricingService.getAllPricing(); + return this.response(data); + } + + @ApiOperation("create pricing") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreatePricingDTO) + @ApiAuth() + @httpPost("/pricing", Guard.authAdmin(), ValidationMiddleware.validateInput(CreatePricingDTO)) + public async createPricing(@requestBody() createDto: CreatePricingDTO) { + const data = await this.pricingService.createPricing(createDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update pricing") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(UpdatePricingDTO) + @ApiParam("id", "id of pricing", true) + @ApiAuth() + @httpPatch( + "/pricing/:id", + Guard.authAdmin(), + ValidationMiddleware.validateParameter(ParamDto), + ValidationMiddleware.validateInput(UpdatePricingDTO), + ) + public async updatePricing(@requestBody() updateDto: UpdatePricingDTO, @requestParam() paramDto: ParamDto) { + const data = await this.pricingService.updatePricing(paramDto.id, updateDto); + return this.response(data); + } + + @ApiOperation("delete pricing") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of pricing", true) + @ApiAuth() + @httpDelete("/pricing/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async deletePricing(@requestParam() paramDto: ParamDto) { + const data = await this.pricingService.deletePricing(paramDto.id); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/fine.controller.ts b/src/modules/admin/controllers/fine.controller.ts new file mode 100644 index 0000000..452e2f5 --- /dev/null +++ b/src/modules/admin/controllers/fine.controller.ts @@ -0,0 +1,74 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, queryParam, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { FineRuleDTO } from "../../fine/DTO/fineRule.dto"; +import { UpdateFineRuleDTO } from "../../fine/DTO/role-update.dto"; +import { FineService } from "../../fine/fine.service"; + +@ApiTags("Admin Fine") +@controller("/admin/fine") +export class AdminFineController extends BaseController { + @inject(IOCTYPES.FineService) private fineService: FineService; + + @ApiOperation("create fine rule") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(FineRuleDTO) + @ApiAuth() + @httpPost("/rule", Guard.authAdmin()) + public async createRule(@requestBody() fineRuleDto: FineRuleDTO) { + const data = await this.fineService.createFineRule(fineRuleDto); + return this.response(data); + } + + @ApiOperation("update fine rule") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of rule") + @ApiModel(UpdateFineRuleDTO) + @ApiAuth() + @httpPatch("/rule/:id/update", Guard.authAdmin()) + public async updateRule(@requestParam("id") id: string, @requestBody() updateDto: UpdateFineRuleDTO) { + const data = await this.fineService.updateFineRule(id, updateDto); + return this.response(data); + } + + @ApiOperation("get one fine rule") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of rule") + @ApiAuth() + @httpGet("/rule/:id", Guard.authAdmin()) + public async getRule(@requestParam("id") id: string) { + const data = await this.fineService.getRuleWithId(id); + return this.response(data); + } + + @ApiOperation("delete one fine rule") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of rule") + @ApiAuth() + @httpDelete("/rule/:id/delete", Guard.authAdmin()) + public async deleteRule(@requestParam("id") id: string) { + const data = await this.fineService.deleteRule(id); + return this.response(data); + } + + @ApiOperation("get all fine ==> login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of fines data") + @ApiQuery("page", "the page want to get") + @ApiAuth() + @httpGet("/fines", Guard.authAdmin()) + public async getAllFinesBySeller(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.fineService.getAllFines(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, fineList: data.fineList }); + } +} diff --git a/src/modules/admin/controllers/learning.controller.ts b/src/modules/admin/controllers/learning.controller.ts new file mode 100644 index 0000000..1f894d4 --- /dev/null +++ b/src/modules/admin/controllers/learning.controller.ts @@ -0,0 +1,89 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CreateLearningDTO } from "../../learning/DTO/createLearning.dto"; +import { CreateLearningCategoryDTO } from "../../learning/DTO/createLearningCategory.dto"; +import { UpdateLearningDTO } from "../../learning/DTO/updateLearning.dto"; +import { UpdateLearningCategoryDTO } from "../../learning/DTO/updateLearningCategory.dto"; +import { LearningService } from "../../learning/learning.service"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Learning") +@controller("/admin/learning") +export class AdminLearningController extends BaseController { + @inject(IOCTYPES.LearningService) private learningService: LearningService; + + //learning + @ApiOperation("create a learning category") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateLearningCategoryDTO) + @ApiAuth() + @httpPost("/category", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateLearningCategoryDTO)) + public async createLearningCategory(@requestBody() createLearningCategoryDto: CreateLearningCategoryDTO) { + const data = await this.learningService.createLearningCategory(createLearningCategoryDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("create a learning") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateLearningDTO) + @ApiAuth() + @httpPost("", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateLearningDTO)) + public async createLearning(@requestBody() createLearningDto: CreateLearningDTO) { + const data = await this.learningService.createLearning(createLearningDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all learning progress") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/progress", Guard.authAdmin()) + public async getLearningProgressBySeller() { + const data = await this.learningService.getLearningProgressBySellerForAdmin(); + return this.response(data); + } + + @ApiOperation("update a learning") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateLearningDTO) + @ApiParam("id", "id of learning", true) + @ApiAuth() + @httpPatch("/:id/update", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateLearningDTO)) + public async updateLearning(@requestBody() updateLearningDto: UpdateLearningDTO, @requestParam("id") learningId: string) { + console.log(learningId, "ssss"); + const data = await this.learningService.update(learningId, updateLearningDto); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("update a learning") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateLearningCategoryDTO) + @ApiParam("id", "id of learning category", true) + @ApiAuth() + @httpPatch("/category/:id/update", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateLearningCategoryDTO)) + public async updateLearningCategory( + @requestBody() updateLearningCategoryDto: UpdateLearningCategoryDTO, + @requestParam("id") catId: string, + ) { + const data = await this.learningService.updateCategory(catId, updateLearningCategoryDto); + return this.response(data, HttpStatus.Ok); + } + + //TODO:add delete route for category of learning + + @ApiOperation("delete a learning") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "learning id", true) + @ApiAuth() + @httpDelete("/:id", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteLearning(@requestParam("id") learningId: string) { + const data = await this.learningService.delete(learningId); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/media.controller.ts b/src/modules/admin/controllers/media.controller.ts new file mode 100644 index 0000000..1f2b01e --- /dev/null +++ b/src/modules/admin/controllers/media.controller.ts @@ -0,0 +1,165 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { UploadService } from "../../../utils/upload.service"; +import { MediaService } from "../../media/media.service"; +import { ActivateMediaDTO, CreateBannerDTO, CreateSliderDTO, UpdateBannerDTO, UpdateSliderDTO } from "../DTO/media.dto"; + +@ApiTags("Admin Media") +@controller("/admin/medias") +export class AdminMediaController extends BaseController { + @inject(IOCTYPES.MediaService) private mediaService: MediaService; + + //==============================================> + // #region setting and medias + @ApiOperation("get all sliders") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/slider", Guard.authAdmin()) + public async getAllSliders() { + const data = await this.mediaService.getAllSlidersForAdmin(); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("get all banners") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/banners", Guard.authAdmin()) + public async getAllBanners() { + const data = await this.mediaService.getAllBannersForAdmin(); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("Create a new slider") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateSliderDTO) + @ApiAuth() + @httpPost("/slider", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateSliderDTO)) + public async createSlider(@requestBody() createSliderDto: CreateSliderDTO) { + const data = await this.mediaService.createSlider(createSliderDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("Create a new banner") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateBannerDTO) + @ApiAuth() + @httpPost("/banner", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateBannerDTO)) + public async createBanner(@requestBody() createBannerDTO: CreateBannerDTO) { + const data = await this.mediaService.createBanner(createBannerDTO); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("Update an existing slider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("sliderId", "id of slider", true) + @ApiModel(UpdateSliderDTO) + @ApiAuth() + @httpPost("/slider/:sliderId", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateSliderDTO)) + public async updateSlider(@requestParam("sliderId") sliderId: string, @requestBody() updateSliderDto: UpdateSliderDTO) { + const data = await this.mediaService.updateSlider(sliderId, updateSliderDto); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("Update an existing banner") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("bannerId", "id of banner", true) + @ApiModel(UpdateBannerDTO) + @ApiAuth() + @httpPost("/banner/:bannerId", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateBannerDTO)) + public async updateBanner(@requestParam("bannerId") bannerId: string, @requestBody() updateBannerDTO: UpdateBannerDTO) { + const data = await this.mediaService.updateBanner(bannerId, updateBannerDTO); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("Delete a slider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("sliderId", "id of slider", true) + @ApiAuth() + @httpDelete("/slider/:sliderId", Guard.authAdmin()) + public async deleteSlider(@requestParam("sliderId") sliderId: string) { + const data = await this.mediaService.deleteMedia(sliderId, "slider"); + return this.response(data); + } + + @ApiOperation("Delete a banner") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("bannerId", "id of banner", true) + @ApiAuth() + @httpDelete("/banner/:bannerId", Guard.authAdmin()) + public async deleteBanner(@requestParam("bannerId") bannerId: string) { + const data = await this.mediaService.deleteMedia(bannerId, "banner"); + return this.response(data); + } + + @ApiOperation("Activate/Deactivate a slider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("sliderId", "id of slider", true) + @ApiModel(ActivateMediaDTO) + @ApiAuth() + @httpPatch("/slider/:sliderId/activate", Guard.authAdmin(), ValidationMiddleware.validateInput(ActivateMediaDTO)) + public async activateSlider(@requestParam("sliderId") sliderId: string, @requestBody() activateDto: ActivateMediaDTO) { + const data = await this.mediaService.activateMediaS(sliderId, activateDto.isActive, "slider"); + return this.response(data); + } + + @ApiOperation("Activate/Deactivate a banner") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("bannerId", "id of banner", true) + @ApiModel(ActivateMediaDTO) + @ApiAuth() + @httpPatch("/banner/:bannerId/activate", Guard.authAdmin(), ValidationMiddleware.validateInput(ActivateMediaDTO)) + public async activateMedia(@requestParam("bannerId") bannerId: string, @requestBody() activateDto: ActivateMediaDTO) { + const data = await this.mediaService.activateMediaS(bannerId, activateDto.isActive, "banner"); + return this.response(data); + } + + @ApiOperation("Upload a single file ==> need to login as admin") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("file", true, true) + @ApiAuth() + @httpPost("/upload/single", Guard.authAdmin(), UploadService.single("file", "media-file")) + public async uploadBlogImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + }, + }; + return this.response(data, HttpStatus.Accepted); + } + + @ApiOperation("Upload a multiple files ==> need to login as admin") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("file", true, true) + @ApiAuth() + @httpPost("/upload/multiple", Guard.authAdmin(), UploadService.multiple("file", 10, "media-file")) + public async uploadImages(@request() req: Request) { + // eslint-disable-next-line no-undef + const files = req.files as Express.MulterS3.File[]; + const data = { + message: "files uploaded!", + urls: files.map((file) => ({ + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + })), + }; + return this.response(data, HttpStatus.Accepted); + } + + // #endregion +} diff --git a/src/modules/admin/controllers/notification.controller.ts b/src/modules/admin/controllers/notification.controller.ts new file mode 100644 index 0000000..fbcdd91 --- /dev/null +++ b/src/modules/admin/controllers/notification.controller.ts @@ -0,0 +1,111 @@ +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CreateNotificationForAllSellerDTO, CreateNotificationForSpecificDTO } from "../../notification/DTO/notification-create.dto"; +import { NotificationType } from "../../notification/models/Abstraction/INotification"; +import { NotificationService } from "../../notification/notification.service"; + +@ApiTags("Admin Notification") +@controller("/admin/notification") +export class AdminNotificationController extends BaseController { + @inject(IOCTYPES.NotificationService) private notificationService: NotificationService; + + //notification + @ApiOperation("get all admin notification") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("unread", "specify the read status ==> value = true") + @ApiQuery("type", "specify the type of notification ==> value = ticket | order | fine | wallet | announcement | product | shipment") + @ApiQuery( + "since", + "time of notification created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiAuth() + @httpGet("", Guard.authAdmin()) + public async getAllNotification( + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("unread") unread: string, + @queryParam("type") type: string, + @queryParam("since") since: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + unread: !!unread, + type: type as NotificationType, + since: +since, + }; + const { count, notifications } = await this.notificationService.getAdminNotifications(queries); + const { pager } = this.paginate(count); + return this.response({ pager, notifications }); + } + + @ApiOperation("mark admin notification as read") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "notification id") + @ApiAuth() + @httpPost("/:id/read", Guard.authAdmin()) + public async markAsRead(@requestParam("id") notificationId: string) { + const data = await this.notificationService.markAsRead(notificationId); + return this.response(data); + } + + @ApiOperation("create notification for all sellers ===> login as admin") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateNotificationForAllSellerDTO) + @ApiAuth() + @httpPost("/allSeller", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateNotificationForAllSellerDTO)) + public async createNotificationForAllSellers(@requestBody() createDto: CreateNotificationForAllSellerDTO) { + const data = await this.notificationService.notifyToAllSeller(createDto); + return this.response(data); + } + + @ApiOperation("create notification for Specific sellers ===> login as admin") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateNotificationForSpecificDTO) + @ApiAuth() + @httpPost("/specific-sellers", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateNotificationForSpecificDTO)) + public async createNotificationForSpecificSellers(@requestBody() createDto: CreateNotificationForSpecificDTO) { + const data = await this.notificationService.notifyToSpecificSellers(createDto); + return this.response(data); + } + + @ApiOperation("get all seller notification that admin sends to all seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("unread", "specify the read status ==> value = true") + @ApiQuery("type", "specify the type of notification ==> value = ticket | order | fine | wallet | announcement | product | shipment") + @ApiQuery( + "since", + "time of notification created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiAuth() + @httpGet("/allSeller", Guard.authAdmin()) + public async getNotificationForAllSellers( + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("unread") unread: string, + @queryParam("type") type: string, + @queryParam("since") since: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + unread: !!unread, + type: type as NotificationType, + since: +since, + }; + const { count, notifications } = await this.notificationService.getNotifyAllSellerForAdmin(queries); + const { pager } = this.paginate(count); + return this.response({ pager, notifications }); + } +} diff --git a/src/modules/admin/controllers/order.controller.ts b/src/modules/admin/controllers/order.controller.ts new file mode 100644 index 0000000..7b45e01 --- /dev/null +++ b/src/modules/admin/controllers/order.controller.ts @@ -0,0 +1,231 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPost, queryParam, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CancelService } from "../../cancel/cancel.service"; +import { CreateCancelDTO } from "../../cancel/DTO/cancel.dto"; +import { CancelReasonDTO } from "../../cancel/DTO/cancelReason.dto"; +import { OrderService } from "../../order/order.service"; +import { ReasonDTO, UpdateReasonDTO } from "../../return/DTO/returnReason.dto"; +import { ReturnService } from "../../return/return.service"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { IAdmin } from "../models/Abstraction/IAdmin"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Order") +@controller("/admin/orders") +export class AdminOrderController extends BaseController { + @inject(IOCTYPES.CancelService) private cancelService: CancelService; + @inject(IOCTYPES.OrderService) private orderService: OrderService; + @inject(IOCTYPES.ReturnService) private returnService: ReturnService; + + //==============================================> + //==============================================> + // #region return + + @ApiOperation("get list of orders that returned ==> login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiAuth() + @httpGet("/returns", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getReturnOrders(@queryParam() paginationDto: PaginationDTO) { + const data = await this.returnService.getAllReturnOrders(paginationDto); + const { pager } = this.paginate(data.count); + return this.response({ pager, returnsOrders: data.returnsOrders }); + } + + @ApiOperation("approve a return order request with return order id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("returnOrderId", "id of return order", true) + @ApiAuth() + @httpPost("/returns/:returnOrderId/approve", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ORDER])) + public async approveReturnRequest(@requestParam("returnOrderId") returnOrderId: string) { + const data = await this.returnService.approveReturnRequest(returnOrderId); + return this.response(data); + } + + @ApiOperation("reject a return order request with return order id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("returnOrderId", "id of return order", true) + @ApiAuth() + @httpPost("/returns/:returnOrderId/reject", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ORDER])) + public async rejectReturnRequest(@requestParam("returnOrderId") returnOrderId: string) { + const data = await this.returnService.rejectReturnRequest(returnOrderId); + return this.response(data); + } + + @ApiOperation("complete a return order request with return order id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("returnOrderId", "id of return order", true) + @ApiAuth() + @httpPost("/returns/:returnOrderId/complete", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ORDER])) + public async completeReturnRequest(@requestParam("returnOrderId") returnOrderId: string) { + const data = await this.returnService.completeReturnRequest(returnOrderId); + return this.response(data); + } + + @ApiOperation("create a return reason") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(ReasonDTO) + @ApiAuth() + @httpPost("/returns/reasons", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async createReturnReason(@requestBody() reasonDto: ReasonDTO) { + const data = await this.returnService.createReturnReason(reasonDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a return reason") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("reasonId", "id of return reason", true) + @ApiModel(UpdateReasonDTO) + @ApiAuth() + @httpPost("/returns/reasons/:reasonId", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async updateReturnReason(@requestParam("reasonId") reasonId: string, @requestBody() updateReasonDto: UpdateReasonDTO) { + const data = await this.returnService.updateReturnReason(reasonId, updateReasonDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a return reason") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("reasonId", "id of return reason", true) + @ApiAuth() + @httpDelete("/returns/reasons/:reasonId", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteReturnReason(@requestParam("reasonId") reasonId: string) { + const data = await this.returnService.deleteReturnReason(reasonId); + return this.response(data); + } + + //cancel orders + + @ApiOperation("get list of orders that canceled ==> login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of canceled data") + @ApiQuery("page", "the page want to get") + @ApiAuth() + @httpGet("/cancel", Guard.authAdmin()) + public async getCancelOrders(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.cancelService.getAllCancelOrders(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, cancelsOrders: data.cancelsOrders }); + } + + @ApiOperation("cancel an item of order ==> login as admin") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateCancelDTO) + @ApiAuth() + @httpPost("/cancel", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateCancelDTO)) + public async createCancelByAdmin(@request() req: Request, @requestBody() cancelDto: CreateCancelDTO) { + const admin = req.user as IAdmin; + const data = await this.cancelService.createCancelOrderByAdmin(admin._id.toString(), cancelDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("create a cancel reason") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CancelReasonDTO) + @ApiAuth() + @httpPost("/cancel/reasons", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async createCancelReason(@requestBody() cancelReasonDto: CancelReasonDTO) { + const data = await this.cancelService.createCancelReason(cancelReasonDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a cancel reason with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of reason") + @ApiAuth() + @httpDelete("/reasons/:id/delete", Guard.authAdmin()) + public async deleteCancelReason(@requestParam("id") reasonId: string) { + const data = await this.cancelService.deleteCancelReason(reasonId); + return this.response(data); + } + + //get orders for admin panel + @ApiOperation("get all native shop orders") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("shipperId", "shipper id") + @ApiQuery("status", "status of payment ==> value = Completed | Cancelled | Pending ") + @ApiQuery( + "since", + "time of order created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiQuery("maxPrice", "search based on payment priceRange") + @ApiQuery("minPrice", "search based on payment priceRange") + @ApiAuth() + @httpGet("/native", Guard.authAdmin()) + public async getNativeOrders( + @request() req: Request, + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("shipperId") shipperId: string, + @queryParam("status") status: string, + @queryParam("since") since: string, + @queryParam("maxPrice") maxPrice: string, + @queryParam("minPrice") minPrice: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + shipperId, + status, + since: +since, + maxPrice: +maxPrice, + minPrice: +minPrice, + }; + const admin = req.user as IAdmin; + const { priceRange, ...data } = await this.orderService.getAdminOrders(admin._id.toString(), queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, orders: data.orders, priceRange }); + } + + @ApiOperation("get all seller orders") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("shipperId", "shipper id") + @ApiQuery("status", "status of payment ==> value = Completed | Cancelled | Pending ") + @ApiQuery( + "since", + "time of order created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiQuery("maxPrice", "search based on payment priceRange") + @ApiQuery("minPrice", "search based on payment priceRange") + @ApiAuth() + @httpGet("/seller", Guard.authAdmin()) + public async getSellerOrders( + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("shipperId") shipperId: string, + @queryParam("status") status: string, + @queryParam("since") since: string, + @queryParam("maxPrice") maxPrice: string, + @queryParam("minPrice") minPrice: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + shipperId, + status, + since: +since, + maxPrice: +maxPrice, + minPrice: +minPrice, + }; + const { priceRange, ...data } = await this.orderService.getSellerOrders(OwnerRef.SELLER, queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, orders: data.orders, priceRange }); + } +} diff --git a/src/modules/admin/controllers/product.controller.ts b/src/modules/admin/controllers/product.controller.ts new file mode 100644 index 0000000..1d5c3e9 --- /dev/null +++ b/src/modules/admin/controllers/product.controller.ts @@ -0,0 +1,510 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { + controller, + httpDelete, + httpGet, + httpPatch, + httpPost, + queryParam, + request, + requestBody, + requestParam, +} from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { AddVariantDTO } from "../../product/DTO/addVariant.dto"; +import { ProductFinalStepDTO, ProductStepOneDTO, ProductStepTwoDTO } from "../../product/DTO/CreateProduct.dto"; +import { CreateAnswerQuestionDTO } from "../../product/DTO/createQuestion.dto"; +import { CreateReportQuestionDTO } from "../../product/DTO/createReportQuestion.dto"; +import { RejectCommentDTO } from "../../product/DTO/product.dto"; +import { ProductParamDto } from "../../product/DTO/productParam.dto"; +import { + AddVoiceToProductDTO, + UpdateProductDTO, + UpdateProductFinalStepDTO, + UpdateProductStepOneDTO, + UpdateProductStepTwoDTO, +} from "../../product/DTO/updateProduct.dto"; +import { ActivateVariantDTO, UpdateVariantDTO } from "../../product/DTO/updateVariant.dto"; +import { ProductRequestService } from "../../product/providers/product-request.service"; +import { ProductService } from "../../product/providers/product.service"; +import { SellerProductQueryDTO } from "../../seller/DTO/sellerProductQuery.dto"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { AddIncredibleOffersParamDto } from "../DTO/product-param.dto"; +import { IAdmin } from "../models/Abstraction/IAdmin"; + +@ApiTags("Admin Product") +@controller("/admin/products") +export class AdminProductController extends BaseController { + @inject(IOCTYPES.ProductService) private productService: ProductService; + @inject(IOCTYPES.ProductRequestService) private productRequestService: ProductRequestService; + + @ApiOperation("first step to create a single product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepOneDTO) + @ApiAuth() + @httpPost("/creation/detail", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductStepOneDTO)) + public async createProduct(@requestBody() createProductStepOneDto: ProductStepOneDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.createProductS(createProductStepOneDto, admin._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get single product details") + @ApiResponse("successfully") + @ApiParam("id", "the product id", true) + @ApiAuth() + @httpGet("/:id/details", Guard.authAdmin()) + public async getSingleProduct(@requestParam("id") productId: string) { + const data = await this.productService.getProductDetailsForAdminPanel(+productId); + return this.response(data); + } + + @ApiOperation("second step to create a single product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepTwoDTO) + @ApiAuth() + @httpPost("/creation/attribute", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductStepTwoDTO)) + public async createProductStepTwo(@requestBody() createProductStepTwoDto: ProductStepTwoDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.createProductStepTwoS(createProductStepTwoDto, admin._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("final step to create a single product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductFinalStepDTO) + @ApiAuth() + @httpPost("/creation/save", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductFinalStepDTO)) + public async createProductFinalStep(@requestBody() createProductFinalStepDto: ProductFinalStepDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.createProductFinalStepS(createProductFinalStepDto, admin._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("add voice to product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(AddVoiceToProductDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPatch("/:id/add-voice", Guard.authAdmin(), ValidationMiddleware.validateInput(AddVoiceToProductDTO)) + public async addVoiceToProduct(@requestBody() addVoiceToProductDto: AddVoiceToProductDTO, @requestParam("id") productId: string) { + const data = await this.productService.addVoiceToProduct(+productId, addVoiceToProductDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete voice from product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPatch("/:id/delete-voice", Guard.authAdmin()) + public async deleteVoiceToProduct(@requestParam("id") productId: string) { + const data = await this.productService.deleteVoiceToProduct(+productId); + return this.response(data, HttpStatus.Created); + } + + //update product if it is in draft status + @ApiOperation("first step to update a single product ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductStepOneDTO) + @ApiAuth() + @httpPatch("/update/detail", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateProductStepOneDTO)) + public async updateProductStepOne(@requestBody() updateProductStepOneDto: UpdateProductStepOneDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.updateProductStepOneS(updateProductStepOneDto, admin._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("second step to update a single product ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductStepTwoDTO) + @ApiAuth() + @httpPatch("/update/attribute", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateProductStepTwoDTO)) + public async updateProductStepTwo(@requestBody() updateProductStepTwoDto: UpdateProductStepTwoDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.updateProductStepTwoS(updateProductStepTwoDto, admin._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("final step to update a single product ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductFinalStepDTO) + @ApiAuth() + @httpPatch("/update/save", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateProductFinalStepDTO)) + public async updateProductFinalStep(@requestBody() updateProductFinalStepDto: UpdateProductFinalStepDTO, @request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.productService.updateProductFinalStepS(updateProductFinalStepDto, admin._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("mark a product as draft") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of product") + @ApiAuth() + @httpPost("/:id/draft", Guard.authAdmin()) + public async draftProduct(@requestParam("id") id: string) { + const data = await this.productService.draft(+id); + return this.response(data); + } + + @ApiOperation("approve a product status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of product") + @ApiAuth() + @httpPost("/:id/approve", Guard.authAdmin()) + public async approveProduct(@requestParam("id") id: string) { + const data = await this.productService.approve(+id); + return this.response(data); + } + + @ApiOperation("pending a product status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of product") + @ApiAuth() + @httpPost("/:id/pending", Guard.authAdmin()) + public async pendingProduct(@requestParam("id") id: string) { + const data = await this.productService.pending(+id); + return this.response(data); + } + + @ApiOperation("reject a product status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of product") + @ApiModel(RejectCommentDTO) + @ApiAuth() + @httpPost("/:id/reject", Guard.authAdmin(), ValidationMiddleware.validateInput(RejectCommentDTO)) + public async rejectProduct(@requestParam("id") id: string, @requestBody() rejectCommentDto: RejectCommentDTO) { + const data = await this.productService.reject(+id, rejectCommentDto); + return this.response(data); + } + + @ApiOperation("get products questions") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("productId", "product id") + @ApiQuery("status", "status of question ==> value = Approved | Rejected | Pending ") + @ApiAuth() + @httpGet("/questions", Guard.authAdmin()) + public async productsQuestions( + @queryParam("page") page: string, + @queryParam("limit") limit: string, + @queryParam("productId") productId: string, + @queryParam("status") status: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + productId: parseInt(productId), + status, + }; + const data = await this.productService.getQuestionsForAdminPanel(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, ...data }); + } + + @ApiOperation("approve a question status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of question") + @ApiAuth() + @httpPost("/questions/:id/approve", Guard.authAdmin()) + public async approveQuestion(@requestParam("id") id: string) { + const data = await this.productService.approveQuestion(id); + return this.response(data); + } + + @ApiOperation("answer to a question with its id ====> need to login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of question") + @ApiModel(CreateAnswerQuestionDTO) + @ApiAuth() + @httpPost("/questions/:id/answer", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateAnswerQuestionDTO)) + public async answerQuestion(@requestParam("id") id: string, @requestBody() createDto: CreateAnswerQuestionDTO, @request() req: Request) { + const user = req.user as IAdmin; + const data = await this.productService.answerQuestion(id, createDto, user._id.toString(), OwnerRef.ADMIN); + return this.response(data); + } + + @ApiOperation("get products comments") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("productId", "product id") + @ApiQuery("status", "status of product ==> value = Approved | Rejected | Pending ") + @ApiAuth() + @httpGet("/comments", Guard.authAdmin()) + public async productsComments( + @queryParam("page") page: string, + @queryParam("limit") limit: string, + @queryParam("productId") productId: string, + @queryParam("status") status: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + productId: parseInt(productId), + status, + }; + const { comments, count } = await this.productService.getCommentsForAdminPanel(queries); + const { pager } = this.paginate(count); + return this.response({ pager, comments }); + } + + @ApiOperation("approve a comments status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of question") + @ApiAuth() + @httpPost("/comments/:id/approve", Guard.authAdmin()) + public async approveComment(@requestParam("id") id: string) { + const data = await this.productService.approveComment(id); + return this.response(data); + } + //product variant + + @ApiOperation("Activate/Deactivate a product variant status") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiModel(ActivateVariantDTO) + @ApiAuth() + @httpPost("/:id/variants/activate", Guard.authAdmin(), ValidationMiddleware.validateInput(ActivateVariantDTO)) + public async activateVariant( + @requestBody() activateDto: ActivateVariantDTO, + @request() req: Request, + @requestParam("id") productId: string, + ) { + const admin = req.user as IAdmin; + const data = await this.productService.activateVariantS(admin._id.toString(), activateDto, +productId); + return this.response(data); + } + + @ApiOperation("add a variant to product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(AddVariantDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPost("/:id/variants", Guard.authAdmin(), ValidationMiddleware.validateInput(AddVariantDTO)) + public async addVariant(@requestBody() addVariantDto: AddVariantDTO, @request() req: Request, @requestParam("id") productId: string) { + const admin = req.user as IAdmin; + const data = await this.productService.addVariantS(addVariantDto, admin._id.toString(), +productId); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("add a popular to product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPost("/:id/popular", Guard.authAdmin()) + public async addPopularProduct(@requestParam("id") productId: string) { + const data = await this.productService.addPopularProduct(+productId); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a popular product with its productId") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("productId", "id of a product", true) + @ApiAuth() + @httpDelete("/:productId/popular/delete", Guard.authAdmin()) + public async deletePopularProduct(@requestParam("productId") productId: string) { + const data = await this.productService.deletePopularProduct(+productId); + return this.response(data); + } + + @ApiOperation("add a product to incredible product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiParam("variantId", "id of product variant", true) + @ApiAuth() + @httpPost("/:id/incredible/:variantId", Guard.authAdmin(), ValidationMiddleware.validateParameter(AddIncredibleOffersParamDto)) + public async addIncredibleProduct(@requestParam() paramDto: AddIncredibleOffersParamDto) { + const data = await this.productService.addIncredibleOffers(paramDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a variant of product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateVariantDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPatch("/:id/variants", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateVariantDTO)) + public async updateVariant( + @requestBody() updateVariantDto: UpdateVariantDTO, + @request() req: Request, + @requestParam("id") productId: string, + ) { + const admin = req.user as IAdmin; + const data = await this.productService.updateVariantS(updateVariantDto, admin._id.toString(), +productId); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a product") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPatch("/:id/update", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateProductDTO)) + public async updateProduct(@requestBody() updateDto: UpdateProductDTO, @requestParam("id") productId: string) { + const data = await this.productService.updateProduct(+productId, updateDto); + return this.response(data, HttpStatus.Created); + } + + /** */ + @ApiOperation("get a single product variant ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiParam("variantId", "id of product variant", true) + @ApiAuth() + @httpGet("/:id/variants/:variantId", Guard.authAdmin()) + public async getSingleVariant( + @request() req: Request, + @requestParam("id") productId: string, + @requestParam("variantId") variantId: string, + ) { + const admin = req.user as IAdmin; + const data = await this.productService.getSingleVariant(admin._id.toString(), +productId, variantId); + return this.response(data); + } + + @ApiOperation("get all variants of a product ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiQuery("page", "the page want to get") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("variant_status", "status of variant ==> value = 1 if want to include those with this status") + @ApiQuery("shipment_method", "shipment method id of product variant") + @ApiQuery("stock", "stock status of variant ==> value 1 if want to include those with this status") + @ApiQuery("include_ad", "include_ad status ==> value = 1 if want to include those with this status ") + @ApiQuery("special_sale", "special_sale status ==> value = 1 if want to include those with this status") + @ApiQuery("sort", "sort variant ==> value = createdAt", false, "array") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiAuth() + @httpGet("/:id/variants", Guard.authAdmin()) + public async getVariant( + @request() req: Request, + @requestParam("id") productId: string, + @queryParam("page") page: string, + @queryParam("limit") limit: string, + @queryParam("variant_status") variantStatus: string, + @queryParam("shipment_method") shipmentMethod: string, + @queryParam("stock") stock: string, + @queryParam("include_ad") includeAd: string, + @queryParam("special_sale") specialSale: string, + @queryParam("sort") sort: string[], + @queryParam("q") q: string, + ) { + const queries = { + page: parseInt(page), + limit: parseInt(limit), + variantStatus: !!variantStatus, + shipmentMethod: +shipmentMethod, + stock: !!stock, + includeAd: !!includeAd, + specialSale: !!specialSale, + sort, + q, + }; + const admin = req.user as IAdmin; + const data = await this.productService.getAllVariantS(admin._id.toString(), +productId, queries); + return this.response(data); + } + + @ApiOperation("delete a product with its id ==> login as admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of a product", true) + @ApiAuth() + @httpDelete("/:id/delete", Guard.authAdmin(), ValidationMiddleware.validateParameter(ProductParamDto)) + public async deleteProduct(@request() req: Request, @requestParam() paramDto: ProductParamDto) { + const admin = req.user as IAdmin; + const data = await this.productService.softDelete(admin._id.toString(), paramDto.id); + return this.response(data); + } + + @ApiOperation("create report question for product report") + @ApiAuth() + @ApiModel(CreateReportQuestionDTO) + @httpPost("/report-question", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateReportQuestionDTO)) + public async createReportQuestion(@requestBody() createDto: CreateReportQuestionDTO) { + const data = await this.productService.createReportQuestion(createDto); + return this.response(data); + } + + //get product + + @ApiOperation("get all products reports") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/user-reports", Guard.authAdmin()) + public async getProductRequests() { + const data = await this.productService.getProductReportForAdmin(); + return this.response(data); + } + + @ApiOperation("get all sellers products") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("categoryId", "category id") + @ApiQuery("status", "status of product ==> value = Approved | Rejected | Pending ") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiQuery("since", "time of product created ==> value should be standard shamsi date since that time user want like == > 1400-01-01") + @ApiAuth() + @httpGet("/seller", Guard.authAdmin(), ValidationMiddleware.validateQuery(SellerProductQueryDTO)) + public async getSellerProducts(@queryParam() queryDto: SellerProductQueryDTO) { + const data = await this.productService.getAdminPanelProducts(OwnerRef.SELLER, queryDto); + const { pager } = this.paginate(data.count); + return this.response({ pager, products: data.products, brands: data.brands, categories: data.categories }); + } + + @ApiOperation("get all native shop products") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("categoryId", "category id") + @ApiQuery("status", "status of product ==> value = Approved | Rejected | Pending ") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiQuery("since", "time of product created ==> value should be standard shamsi date since that time user want like == > 1400-01-01") + @ApiAuth() + @httpGet("/native", Guard.authAdmin(), ValidationMiddleware.validateQuery(SellerProductQueryDTO)) + public async getNativeProducts(@request() req: Request, @queryParam() queryDto: SellerProductQueryDTO) { + const admin = req.user as IAdmin; + const data = await this.productService.findSellerProduct(admin._id.toString(), queryDto); + const { pager } = this.paginate(data.count); + return this.response({ pager, products: data.products, brands: data.brands, categories: data.categories }); + } + + @ApiOperation("get all product request from seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/requests/add-by-admin", Guard.authAdmin()) + public async productRequests() { + const data = await this.productRequestService.getProductRequestForAdmin(); + return this.response(data); + } + + @ApiOperation("get all products filtered by status") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("status", "status of product ==> value = Approved | Rejected | Pending ") + @ApiAuth() + @httpGet("/filter-by-status", Guard.authAdmin()) + public async getProductsByStatus( + @queryParam("status") status: string, + @queryParam("limit") limit: string, + @queryParam("page") page: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + status, + }; + const data = await this.productService.filterProductsByStatus(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, products: data.products, brands: data.brands, categories: data.categories }); + } +} diff --git a/src/modules/admin/controllers/role.controller.ts b/src/modules/admin/controllers/role.controller.ts new file mode 100644 index 0000000..4141360 --- /dev/null +++ b/src/modules/admin/controllers/role.controller.ts @@ -0,0 +1,47 @@ +import { inject } from "inversify"; +import { controller, httpGet, httpPost, requestBody } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { AdminService } from "../admin.service"; +import { CreateRoleDTO } from "../DTO/createRole.dto"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin base Role") +@controller("/admin/roles") +export class AdminRoleController extends BaseController { + @inject(IOCTYPES.AdminService) private adminService: AdminService; + + //==============================================> + @ApiOperation("create a role") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateRoleDTO) + @ApiAuth() + @httpPost("/", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN]), ValidationMiddleware.validateInput(CreateRoleDTO)) + public async createRole(@requestBody() createRoleDTO: CreateRoleDTO) { + const data = await this.adminService.createRoleS(createRoleDTO); + return this.response(data); + } + + @ApiOperation("get roles") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/roles", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getRoles() { + const data = await this.adminService.getRoles(); + return this.response(data); + } + + @ApiOperation("get permissions") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/permissions", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getPermissions() { + const data = await this.adminService.getPermissions(); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/setting.controller.ts b/src/modules/admin/controllers/setting.controller.ts new file mode 100644 index 0000000..2b09b7b --- /dev/null +++ b/src/modules/admin/controllers/setting.controller.ts @@ -0,0 +1,237 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, httpPost, queryParam, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { AboutUsService } from "../../about-us/aboutUs.service"; +import { CreateAboutUsDTO } from "../../about-us/DTO/createAboutUsRequest.dto"; +import { ContactUsService } from "../../contact-us/contactUs.service"; +import { CreateFaqDTO } from "../../faq/DTO/createFaq.dto"; +import { UpdateFaqDTO } from "../../faq/DTO/updateFaq.dto"; +import { FaqService } from "../../faq/faq.service"; +import { CreateJobDTO } from "../../job/DTO/createJob.dto"; +import { JobService } from "../../job/job.service"; +import { CreateDocumentTypeDTO } from "../../seller/DTO/createDocumentType"; +import { SellerService } from "../../seller/seller.service"; +import { AdminService } from "../admin.service"; +import { CreateContractDTO, UpdateContractDTO } from "../DTO/contract.dto"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin base Setting") +@controller("/admin/settings") +export class AdminSettingController extends BaseController { + @inject(IOCTYPES.AdminService) private adminService: AdminService; + @inject(IOCTYPES.AboutUsService) private aboutUsService: AboutUsService; + @inject(IOCTYPES.ContactUsService) private contactUsService: ContactUsService; + @inject(IOCTYPES.SellerService) private sellerService: SellerService; + @inject(IOCTYPES.FaqService) private faqService: FaqService; + @inject(IOCTYPES.JobService) private jobService: JobService; + + //=================================================> + // documentType + + @ApiOperation("create document type") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateDocumentTypeDTO) + @ApiAuth() + @httpPost("/document-types", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateDocumentTypeDTO)) + public async createDocumentType(@requestBody() createDocumentTypeDto: CreateDocumentTypeDTO) { + const data = await this.sellerService.createDocumentType(createDocumentTypeDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get content of content") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/contract", Guard.authAdmin()) + public async getContract() { + const data = await this.adminService.getContactContent(); + return this.response(data); + } + //contract + @ApiOperation("create a contract") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateContractDTO) + @ApiAuth() + @httpPost("/contract", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateContractDTO)) + public async createContract(@requestBody() createContractDto: CreateContractDTO) { + const data = await this.adminService.createContentS(createContractDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a contract") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateContractDTO) + @ApiParam("id", "id of contract", true) + @ApiAuth() + @httpPatch("/contract/:id/update", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateContractDTO)) + public async updateContract(@requestBody() updateContractDto: UpdateContractDTO) { + const data = await this.adminService.updateContentS(updateContractDto); + return this.response(data, HttpStatus.Ok); + } + + //about us + @ApiOperation("create a about us") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(CreateAboutUsDTO) + @ApiAuth() + @httpPost("/about-us", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateAboutUsDTO)) + public async createAboutUs(@requestBody() createAboutUs: CreateAboutUsDTO) { + const data = await this.aboutUsService.createAboutUs(createAboutUs); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a about us") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "about id", true) + @ApiAuth() + @httpDelete("/about-us/:id", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteAboutUs(@requestParam("id") aboutId: string) { + const data = await this.aboutUsService.delete(aboutId); + return this.response(data); + } + + //contact-us + @ApiOperation("get all contact-us data") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("email_phone", "email of phone") + @ApiAuth() + @httpGet("/contact-us") + public async getContactUs( + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("email_phone") email_phone: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + email_phone, + }; + const { ...data } = await this.contactUsService.getAllContactUs(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, contactUs: data.contactUs }); + } + + //=================================================> + //faq + + @ApiOperation("get all faqs") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @ApiQuery("page", "the page of faq ==> value Faq | Seller") + @httpGet("/faqs", Guard.authAdmin()) + public async getAllFaqs(@queryParam("page") page: string) { + const data = await this.faqService.getAllFaqs(page); + return this.response(data); + } + + @ApiOperation("create faq") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateFaqDTO) + @ApiAuth() + @httpPost( + "/faqs", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateFaqDTO), + ) + public async createFaq(@requestBody() createDto: CreateFaqDTO) { + const data = await this.faqService.createFaq(createDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update a faq") + @ApiResponse("successful", HttpStatus.Created) + @ApiAuth() + @ApiModel(UpdateFaqDTO) + @httpPatch( + "/faqs", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(UpdateFaqDTO), + ) + public async updateFaq(@requestBody() updateDto: UpdateFaqDTO) { + const data = await this.faqService.updateFaq(updateDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a faq") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "faq id", true) + @ApiAuth() + @httpDelete("/faqs/:id", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteFaq(@requestParam("id") faqId: string) { + const data = await this.faqService.deleteFaq(faqId); + return this.response(data); + } + + // @ApiOperation("delete a faq") + // @ApiResponse("successful", HttpStatus.Ok) + // @ApiParam("id", "faq id", true) + // @ApiAuth() + // @httpDelete("/faqs/:id", Guard.authAdmin(), Guard.checkAdminRole([RoleEnum.SUPERADMIN, RoleEnum.MODERATOR, RoleEnum.EDITOR])) + // public async deleteFaq(@requestParam("id") faqId: string) { + // const data = await this.faqService.deleteFaq(faqId); + // return this.response(data); + // } + + //=================================================> + //job and resume + @ApiOperation("create a job") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateJobDTO) + @ApiAuth() + @httpPost( + "/jobs", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + Guard.checkAdminPermissions([PermissionEnum.JOB]), + ValidationMiddleware.validateInput(CreateJobDTO), + ) + public async createJob(@requestBody() createJobDto: CreateJobDTO) { + const data = await this.jobService.createJob(createJobDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("delete a job") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "job id", true) + @ApiAuth() + @httpDelete("/jobs/:id/delete", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async deleteJob(@requestParam("id") jobId: string) { + const data = await this.jobService.deleteJob(jobId); + return this.response(data); + } + + @ApiOperation("fetch all resume") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiAuth() + @httpGet("/jobs/resume", Guard.authAdmin()) + public async getAllResumes(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.jobService.getAllResumes(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, resumes: data.resumes }); + } + + @ApiOperation("fetch all resume for a job") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "job id", true) + @ApiAuth() + @httpGet("/jobs/:id/resume", Guard.authAdmin()) + public async getJobResumes(@requestParam("id") jobId: string) { + const data = await this.jobService.getResumesForJob(jobId); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/shipment.controller.ts b/src/modules/admin/controllers/shipment.controller.ts new file mode 100644 index 0000000..111af96 --- /dev/null +++ b/src/modules/admin/controllers/shipment.controller.ts @@ -0,0 +1,51 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpPatch, httpPost, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CreateShipProvidersDTO } from "../../shipment/DTO/createShipmentProviders.dto"; +import { UpdateShipmentProviderDTO } from "../../shipment/DTO/updateShipment.dto"; +import { ShipmentService } from "../../shipment/shipment.service"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Shipment") +@controller("/admin/shipment") +export class AdminShipmentController extends BaseController { + @inject(IOCTYPES.ShipmentService) private shipmentService: ShipmentService; + //=================================================> + //shipment + @ApiOperation("create a shipment provider") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateShipProvidersDTO) + @ApiAuth() + @httpPost("", Guard.authAdmin(), ValidationMiddleware.validateInput(CreateShipProvidersDTO)) + public async createShipmentProvider(@requestBody() createShipmentDto: CreateShipProvidersDTO) { + const data = await this.shipmentService.createShipmentProviderS(createShipmentDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("update a shipment provider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "shipment provider id", true) + @ApiModel(UpdateShipmentProviderDTO) + @ApiAuth() + @httpPatch("/:id", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateShipmentProviderDTO)) + public async UpdateShipmentProvider(@requestParam("id") id: string, @requestBody() updateDto: UpdateShipmentProviderDTO) { + const data = await this.shipmentService.updateShipmentProvider(+id, updateDto); + return this.response(data); + } + + @ApiOperation("delete a shipment provider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "shipment provider id", true) + @ApiAuth() + @httpDelete("/:id", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getShipmentProviders(@requestParam("id") shipperId: string) { + const data = await this.shipmentService.deleteShipper(+shipperId); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/shop.controller.ts b/src/modules/admin/controllers/shop.controller.ts new file mode 100644 index 0000000..354e763 --- /dev/null +++ b/src/modules/admin/controllers/shop.controller.ts @@ -0,0 +1,73 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpPatch, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { ProductFinalStepDTO, ProductStepOneDTO, ProductStepTwoDTO } from "../../product/DTO/CreateProduct.dto"; +import { ProductService } from "../../product/providers/product.service"; +import { AdminService } from "../admin.service"; +import { IAdmin } from "../models/Abstraction/IAdmin"; + +@ApiTags("Admin Shop") +@controller("/admin/shop") +export class AdminShopController extends BaseController { + @inject(IOCTYPES.ProductService) private productService: ProductService; + @inject(IOCTYPES.AdminService) private adminService: AdminService; + + //############################################################### + //create product for shop + @ApiOperation("first step to create a single product for shop ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepOneDTO) + @ApiParam("shopId", "id of shop") + @ApiAuth() + @httpPost("/:shopId/creation/detail", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductStepOneDTO)) + public async createProductForShop(@requestBody() createProductStepOneDto: ProductStepOneDTO, @requestParam("shopId") shopId: string) { + const data = await this.productService.createProductForShopS(createProductStepOneDto, shopId); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("second step to create a single product for shop ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepTwoDTO) + @ApiParam("shopId", "id of shop") + @ApiAuth() + @httpPost("/:shopId/creation/attribute", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductStepTwoDTO)) + public async createProductStepTwoForShop( + @requestBody() createProductStepTwoDto: ProductStepTwoDTO, + @requestParam("shopId") shopId: string, + ) { + const data = await this.productService.createProductStepTwoForShopS(createProductStepTwoDto, shopId); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("final step to create a single product for shop ==> need to login as admin") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductFinalStepDTO) + @ApiParam("shopId", "id of shop") + @ApiAuth() + @httpPost("/:shopId/creation/save", Guard.authAdmin(), ValidationMiddleware.validateInput(ProductFinalStepDTO)) + public async createProductFinalStepForShop( + @requestBody() createProductFinalStepDto: ProductFinalStepDTO, + @requestParam("shopId") shopId: string, + ) { + const data = await this.productService.createProductFinalStepForShopS(createProductFinalStepDto, shopId); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("change status of shop chat") + @ApiAuth() + @httpPatch("/chat/status", Guard.authAdmin()) + public async changeShopChatStatus(@request() req: Request) { + const admin = req.user as IAdmin; + const data = await this.adminService.changeShopChatStatus(admin._id.toString()); + return this.response(data); + } + + //############################################################### +} diff --git a/src/modules/admin/controllers/siteSetting.controller.ts b/src/modules/admin/controllers/siteSetting.controller.ts new file mode 100644 index 0000000..9608884 --- /dev/null +++ b/src/modules/admin/controllers/siteSetting.controller.ts @@ -0,0 +1,50 @@ +import { inject } from "inversify"; +import { controller, httpPost, requestBody } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CreateSiteSettingDTO, UpdateSiteSettingDTO } from "../../site-setting/DTO/siteSetting.dto"; +import { SiteSettingService } from "../../site-setting/siteSetting.service"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Site Setting") +@controller("/admin/site-setting") +export class AdminSiteSettingController extends BaseController { + @inject(IOCTYPES.SiteSettingService) private siteSettingService: SiteSettingService; + + @ApiOperation("create a setting for site") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateSiteSettingDTO) + @ApiAuth() + @httpPost( + "", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateSiteSettingDTO), + ) + public async createSiteSetting(@requestBody() createDto: CreateSiteSettingDTO) { + const data = await this.siteSettingService.createSiteSetting(createDto); + return this.response(data, HttpStatus.Created); + } + + //==> + + @ApiOperation("update a setting for site") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(UpdateSiteSettingDTO) + @ApiAuth() + @httpPost( + "/update", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(UpdateSiteSettingDTO), + ) + public async updateSiteSetting(@requestBody() updateDto: UpdateSiteSettingDTO) { + const data = await this.siteSettingService.updateSiteSetting(updateDto); + return this.response(data, HttpStatus.Created); + } +} diff --git a/src/modules/admin/controllers/ticket.controller.ts b/src/modules/admin/controllers/ticket.controller.ts new file mode 100644 index 0000000..3e6601f --- /dev/null +++ b/src/modules/admin/controllers/ticket.controller.ts @@ -0,0 +1,112 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { UploadService } from "../../../utils/upload.service"; +import { CreateTicketCategoryDTO } from "../../ticket/DTO/createTicket.dto"; +import { TicketMessageDTO } from "../../ticket/DTO/ticketMessage.dto"; +import { TicketStatus } from "../../ticket/models/Abstraction/ITicket"; +import { SenderRef } from "../../ticket/models/Abstraction/ITicketMessage"; +import { TicketService } from "../../ticket/ticket.service"; +import { IAdmin } from "../models/Abstraction/IAdmin"; +import { PermissionEnum } from "../models/Abstraction/IPermission"; + +@ApiTags("Admin Ticket") +@controller("/admin/tickets") +export class AdminTicketController extends BaseController { + @inject(IOCTYPES.TicketService) private ticketService: TicketService; + + @ApiOperation("create a category for ticket") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of ticket", true) + @ApiModel(CreateTicketCategoryDTO) + @ApiAuth() + @httpPost( + "/category", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(CreateTicketCategoryDTO), + ) + public async createTicketCategory(@requestBody() createDto: CreateTicketCategoryDTO) { + const data = await this.ticketService.createTicketCategory(createDto); + return this.response(data, HttpStatus.Created); + } + //==> + @ApiOperation("get all tickets") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getAllTickets() { + const data = await this.ticketService.getAllTickets(); + return this.response(data); + } + + @ApiOperation("get ticket with messages") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of ticket", true) + @ApiAuth() + @httpGet("/:id", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async getTicketWithMessages(@requestParam("id") ticketId: string) { + const data = await this.ticketService.getTicketWithMessageForAdmin(ticketId); + return this.response(data); + } + + //==> + + @ApiOperation("add a message to ticket of seller ") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of ticket", true) + @ApiModel(TicketMessageDTO) + @ApiAuth() + @httpPost( + "/:id/add-message", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + ValidationMiddleware.validateInput(TicketMessageDTO), + ) + public async addMessageToTicket( + @requestParam("id") ticketId: string, + @request() req: Request, + @requestBody() messageDto: TicketMessageDTO, + ) { + const admin = req.user as IAdmin; + const data = await this.ticketService.addMessage(ticketId, admin._id.toString(), SenderRef.ADMIN, messageDto); + return this.response(data, HttpStatus.Created); + } + //==> + @ApiOperation("close status of seller ticket with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of ticket", true) + @ApiAuth() + @httpPost("/:id/close", Guard.authAdmin(), Guard.checkAdminPermissions([PermissionEnum.ADMIN])) + public async closeTicket(@requestParam("id") ticketId: string) { + const data = await this.ticketService.updateTicketStatus(ticketId, TicketStatus.CLOSED); + return this.response(data); + } + + @ApiOperation("upload a ticket attachment") + @ApiResponse("successful", HttpStatus.Created) + @ApiFile("attachments", true, true) + @ApiAuth() + @httpPost("/attachment", Guard.authAdmin(), UploadService.multiple("attachments", 5, "ticket-attachments")) + public async ticketAttachment(@request() req: Request) { + // eslint-disable-next-line no-undef + const files = req.files as Express.MulterS3.File[]; + const data = { + message: "files uploaded!", + urls: files.map((file) => ({ + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + })), + }; + return this.response(data, HttpStatus.Accepted); + } +} diff --git a/src/modules/admin/controllers/users.controller.ts b/src/modules/admin/controllers/users.controller.ts new file mode 100644 index 0000000..3263680 --- /dev/null +++ b/src/modules/admin/controllers/users.controller.ts @@ -0,0 +1,133 @@ +import { inject } from "inversify"; +import { controller, httpDelete, httpGet, httpPatch, queryParam, requestBody, requestParam } from "inversify-express-utils"; + +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiTags } from "../../../common/decorator/swggerDocs"; +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { ParamDto } from "../../../common/dto/param.dto"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { SellerService } from "../../seller/seller.service"; +import { UserService } from "../../user/user.service"; +import { RejectReasonSellerDocumentDto, UpdateSellerDocumentDto } from "../DTO/updateSellerDocument.dto"; + +@ApiTags("Admin Users managements") +@controller("/admin") +export class AdminUsersController extends BaseController { + @inject(IOCTYPES.SellerService) private sellerService: SellerService; + @inject(IOCTYPES.UserService) private userService: UserService; + + @ApiOperation("get all sellers") + @ApiQuery("page", "page number", false) + @ApiQuery("limit", "limit per page", false) + @ApiAuth() + @httpGet("/sellers", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getSellers(@queryParam() queryDto: PaginationDTO) { + const { count, sellers } = await this.sellerService.getSellersWithDocumentsAndContracts(queryDto); + const { pager } = this.paginate(count); + return this.response({ sellers, pager }); + } + + @ApiOperation("get all wholesale requests") + @ApiQuery("page", "page number", false) + @ApiQuery("limit", "limit per page", false) + @ApiAuth() + @httpGet("/sellers/wholesale-requests", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getWholesaleRequests(@queryParam() queryDto: PaginationDTO) { + const { count, requests } = await this.sellerService.getWholesaleRequests(queryDto); + const { pager } = this.paginate(count); + return this.response({ requests, pager }); + } + + @ApiOperation("get single seller") + @ApiParam("id", "the seller id", true) + @ApiAuth() + @httpGet("/sellers/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async getSingleSeller(@requestParam() paramDto: ParamDto) { + const data = await this.sellerService.getSellerById(paramDto.id); + return this.response(data); + } + + @ApiOperation("approve seller wholesale") + @ApiParam("id", "the seller id", true) + @ApiAuth() + @httpPatch("/sellers/wholesale-requests/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async approveSellerWholesale(@requestParam() paramDto: ParamDto) { + const data = await this.sellerService.approveSellerWholesale(paramDto.id); + return this.response(data); + } + + @ApiOperation("approve seller registration") + @ApiParam("id", "the seller id", true) + @ApiAuth() + @httpPatch("/sellers/approve-register/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async approveSellerRegistration(@requestParam() paramDto: ParamDto) { + const data = await this.sellerService.approveSellerRegistration(paramDto.id); + return this.response(data); + } + + @ApiOperation("approve seller contract") + @ApiParam("id", "the seller id", true) + @ApiAuth() + @httpPatch("/sellers/approve-contract/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async approveSellerContract(@requestParam() paramDto: ParamDto) { + const data = await this.sellerService.approveSellerContract(paramDto.id); + return this.response(data); + } + + @ApiOperation("approve seller document") + @ApiParam("id", "the seller id", true) + @ApiModel(UpdateSellerDocumentDto) + @ApiAuth() + @httpPatch( + "/sellers/document/:id", + Guard.authAdmin(), + ValidationMiddleware.validateParameter(ParamDto), + ValidationMiddleware.validateInput(UpdateSellerDocumentDto), + ) + public async approveSellerDocs(@requestParam() paramDto: ParamDto, @requestBody() updateDto: UpdateSellerDocumentDto) { + const data = await this.sellerService.updateSellerDocumentStatus(paramDto.id, updateDto); + return this.response(data); + } + + @ApiOperation("delete seller document") + @ApiParam("id", "the document id", true) + @ApiModel(RejectReasonSellerDocumentDto) + @ApiAuth() + @httpDelete( + "/sellers/document/:id", + Guard.authAdmin(), + ValidationMiddleware.validateParameter(ParamDto), + ValidationMiddleware.validateInput(RejectReasonSellerDocumentDto), + ) + public async deleteSellerDocs(@requestParam() paramDto: ParamDto, @requestBody() rejectDto: RejectReasonSellerDocumentDto) { + const data = await this.sellerService.deleteSellerDocument(paramDto.id, rejectDto.reason); + return this.response(data); + } + + // @ApiOperation("get all seller documents") + // @ApiAuth() + // @httpGet("/sellers/documents", Guard.authAdmin()) + // public async getSellerDocuments() {} + + @ApiOperation("get all users") + @ApiQuery("page", "page number", false) + @ApiQuery("limit", "limit per page", false) + @ApiAuth() + @httpGet("/users", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getUsers(@queryParam() queryDto: PaginationDTO) { + const { count, users } = await this.userService.getAllUsers(queryDto); + const { pager } = this.paginate(count); + return this.response({ users, pager }); + } + + @ApiOperation("get single user") + @ApiParam("id", "the user id", true) + @ApiAuth() + @httpGet("/users/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async getSingleUser(@requestParam() paramDto: ParamDto) { + const data = await this.userService.getUserById(paramDto.id); + return this.response(data); + } +} diff --git a/src/modules/admin/controllers/warranty.controller.ts b/src/modules/admin/controllers/warranty.controller.ts new file mode 100644 index 0000000..60372f0 --- /dev/null +++ b/src/modules/admin/controllers/warranty.controller.ts @@ -0,0 +1,62 @@ +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpDelete, httpPatch, httpPost, requestBody, requestParam } from "inversify-express-utils"; + +import { HttpStatus } from "../../../common"; +import { BaseController } from "../../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../../common/decorator/swggerDocs"; +import { appConfig } from "../../../core/config/app.config"; +import { Guard } from "../../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { CreateWarrantyDTO } from "../../warranty/DTO/createWarranty.dto"; +import { UpdateWarrantyDTO } from "../../warranty/DTO/UpdateWarranty.dto"; +import { WarrantyService } from "../../warranty/warranty.service"; + +@ApiTags("Admin Warranty") +@controller("/admin/warranty") +export class AdminWarrantyController extends BaseController { + @inject(IOCTYPES.WarrantyService) private warrantyService: WarrantyService; + //=================================================> + //warranty + @ApiOperation("request to create a new warranty ==> login as admin") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateWarrantyDTO) + @ApiAuth() + @httpPost("", rateLimit(appConfig.rate), Guard.authAdmin(), ValidationMiddleware.validateInput(CreateWarrantyDTO)) + public async createWarranty(@requestBody() createWarrantyDto: CreateWarrantyDTO) { + const data = await this.warrantyService.createWarrantyS(createWarrantyDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("update a warranty with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of warranty") + @ApiModel(UpdateWarrantyDTO) + @ApiAuth() + @httpPatch("/:id", Guard.authAdmin(), ValidationMiddleware.validateInput(UpdateWarrantyDTO)) + public async UpdateWarranty(@requestParam("id") id: string, @requestBody() updateDto: UpdateWarrantyDTO) { + const data = await this.warrantyService.updateById(+id, updateDto); + return this.response({ data }); + } + + @ApiOperation("approve a warranty status with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of warranty") + @ApiAuth() + @httpPost("/:id/approve", Guard.authAdmin()) + public async approveWarranty(@requestParam("id") id: string) { + const data = await this.warrantyService.approve(+id); + return this.response(data); + } + + @ApiOperation("delete a warranty with its id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of warranty") + @ApiAuth() + @httpDelete("/:id", Guard.authAdmin()) + public async deleteWarranty(@requestParam("id") id: string) { + const data = await this.warrantyService.deleteById(+id); + return this.response(data); + } +} diff --git a/src/modules/admin/index.ts b/src/modules/admin/index.ts new file mode 100644 index 0000000..a4335cb --- /dev/null +++ b/src/modules/admin/index.ts @@ -0,0 +1,20 @@ +import "./controllers/admin.controller"; +import "./controllers/coupon.controller"; +import "./controllers/blog.controller"; +import "./controllers/brand.controller"; +import "./controllers/category.controller"; +import "./controllers/financial.controller"; +import "./controllers/learning.controller"; +import "./controllers/media.controller"; +import "./controllers/notification.controller"; +import "./controllers/order.controller"; +import "./controllers/product.controller"; +import "./controllers/setting.controller"; +import "./controllers/shipment.controller"; +import "./controllers/ticket.controller"; +import "./controllers/warranty.controller"; +import "./controllers/fine.controller"; +import "./controllers/siteSetting.controller"; +import "./controllers/users.controller"; +import "./controllers/role.controller"; +import "./controllers/shop.controller"; diff --git a/src/modules/admin/models/Abstraction/IAdmin.ts b/src/modules/admin/models/Abstraction/IAdmin.ts new file mode 100644 index 0000000..af3db38 --- /dev/null +++ b/src/modules/admin/models/Abstraction/IAdmin.ts @@ -0,0 +1,13 @@ +import { Types } from "mongoose"; + +export interface IAdmin { + _id: Types.ObjectId; + fullName: string; + userName: string; + email: string; + password: string; + phoneNumber: string; + role: Types.ObjectId; + permissions: Types.ObjectId[]; + createdBy: Types.ObjectId; +} diff --git a/src/modules/admin/models/Abstraction/IContract.ts b/src/modules/admin/models/Abstraction/IContract.ts new file mode 100644 index 0000000..1bf4829 --- /dev/null +++ b/src/modules/admin/models/Abstraction/IContract.ts @@ -0,0 +1,3 @@ +export interface IContract { + content: string; +} diff --git a/src/modules/admin/models/Abstraction/IMedia.ts b/src/modules/admin/models/Abstraction/IMedia.ts new file mode 100644 index 0000000..7aa10aa --- /dev/null +++ b/src/modules/admin/models/Abstraction/IMedia.ts @@ -0,0 +1,41 @@ +import { Types } from "mongoose"; + +import { DisplayLocations } from "../../../../common/enums/media.enum"; + +// export interface IMedia { +// _id: Types.ObjectId; +// type: MediaType; +// name: string; +// images: IImage[]; +// displayLocation: DisplayLocations; +// isActive: boolean; +// } + +// export interface IImage { +// imageUrl: string; +// altText: string; +// title: string; +// linkUrl: string; +// } + +export interface IBanner { + _id: Types.ObjectId; + // displayLocation: DisplayLocations; + imageUrl: string; + altText: string; + title: string; + linkUrl: string; + isActive: boolean; + order: number; +} + +export interface ISlider { + _id: Types.ObjectId; + displayLocation: DisplayLocations; + imageUrl: string; + altText: string; + title: string; + linkUrl: string; + isActive: boolean; + // order: number; +} diff --git a/src/modules/admin/models/Abstraction/IPermission.ts b/src/modules/admin/models/Abstraction/IPermission.ts new file mode 100644 index 0000000..9f57345 --- /dev/null +++ b/src/modules/admin/models/Abstraction/IPermission.ts @@ -0,0 +1,18 @@ +export interface IPermission { + name: PermissionEnum; +} + +export enum PermissionEnum { + BRAND = "BRAND", + CATEGORY = "CATEGORY", + ITEM = "ITEM", + PRODUCT = "PRODUCT", + ORDER = "ORDER", + USER = "USER", + BLOG = "BLOG", + DISCOUNT = "DISCOUNT", + SITE = "SITE", + ADMIN = "ADMIN", + SHOP = "SHOP", + JOB = "JOB", +} diff --git a/src/modules/admin/models/Abstraction/IRole.ts b/src/modules/admin/models/Abstraction/IRole.ts new file mode 100644 index 0000000..47e412e --- /dev/null +++ b/src/modules/admin/models/Abstraction/IRole.ts @@ -0,0 +1,10 @@ +export interface IRole { + name: RoleEnum; +} + +export enum RoleEnum { + SUPERADMIN = "SUPERADMIN", + MODERATOR = "MODERATOR", + EDITOR = "EDITOR", + ADMIN = "ADMIN", +} diff --git a/src/modules/admin/models/admin.model.ts b/src/modules/admin/models/admin.model.ts new file mode 100644 index 0000000..6cd31ef --- /dev/null +++ b/src/modules/admin/models/admin.model.ts @@ -0,0 +1,32 @@ +import { hash } from "bcrypt"; +import { CallbackError, Schema, model } from "mongoose"; + +import { IAdmin } from "./Abstraction/IAdmin"; + +const adminSchema = new Schema( + { + fullName: { type: String, required: true }, + userName: { type: String, required: true }, + email: { type: String, unique: true }, + password: { type: String, minlength: 6 }, + phoneNumber: { type: String, unique: true }, + role: { type: Schema.Types.ObjectId, ref: "Role", required: true }, + permissions: { type: [Schema.Types.ObjectId], ref: "Permission" }, + createdBy: { type: Schema.Types.ObjectId, ref: "Admin" }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +adminSchema.pre("save", async function (next) { + if (!this.isModified("password")) return next(); + try { + this.password = await hash(this.password, 10); + next(); + } catch (error) { + next(error as CallbackError); + } +}); + +const adminModel = model("Admin", adminSchema); + +export { adminModel as AdminModel }; diff --git a/src/modules/admin/models/contract.model.ts b/src/modules/admin/models/contract.model.ts new file mode 100644 index 0000000..549d584 --- /dev/null +++ b/src/modules/admin/models/contract.model.ts @@ -0,0 +1,13 @@ +import { Schema, model } from "mongoose"; + +import { IContract } from "./Abstraction/IContract"; + +const ContractSchema = new Schema( + { + content: { type: String, required: true }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); +const ContractModel = model("Contract", ContractSchema); + +export { ContractModel }; diff --git a/src/modules/admin/models/media.model.ts b/src/modules/admin/models/media.model.ts new file mode 100644 index 0000000..dcb56b1 --- /dev/null +++ b/src/modules/admin/models/media.model.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IBanner, ISlider } from "./Abstraction/IMedia"; +import { DisplayLocations } from "../../../common/enums/media.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const SliderSchema = new Schema( + { + displayLocation: { type: String, enum: DisplayLocations, required: true }, + imageUrl: { type: String, required: true }, + altText: { type: String, default: "" }, + title: { type: String, default: "" }, + linkUrl: { type: String, required: true }, + isActive: { type: Boolean, default: true }, + // order: Number, + }, + { + timestamps: true, + toJSON: { versionKey: false }, + id: false, + }, +); + +SliderSchema.plugin(AutoIncrement, { id: "slider_id", inc_field: "order" }); + +const BannerSchema = new Schema( + { + // displayLocation: { type: String, enum: DisplayLocations, required: true }, + imageUrl: { type: String, required: true }, + altText: { type: String, default: "" }, + title: { type: String, default: "" }, + linkUrl: { type: String, required: true }, + isActive: { type: Boolean, default: true }, + order: { type: Number, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const SliderModel = model("Slider", SliderSchema); + +const BannerModel = model("Banner", BannerSchema); + +export { SliderModel, BannerModel }; diff --git a/src/modules/admin/models/permission.model.ts b/src/modules/admin/models/permission.model.ts new file mode 100644 index 0000000..1f328cf --- /dev/null +++ b/src/modules/admin/models/permission.model.ts @@ -0,0 +1,14 @@ +import { Schema, model } from "mongoose"; + +import { IPermission, PermissionEnum } from "./Abstraction/IPermission"; + +const permissionSchema = new Schema( + { + name: { type: String, enum: PermissionEnum, unique: true, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const PermissionModel = model("Permission", permissionSchema); + +export { PermissionModel }; diff --git a/src/modules/admin/models/role.model.ts b/src/modules/admin/models/role.model.ts new file mode 100644 index 0000000..28afd0c --- /dev/null +++ b/src/modules/admin/models/role.model.ts @@ -0,0 +1,14 @@ +import { Schema, model } from "mongoose"; + +import { IRole, RoleEnum } from "./Abstraction/IRole"; + +const roleSchema = new Schema( + { + name: { type: String, enum: RoleEnum, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const RoleModel = model("Role", roleSchema); + +export { RoleModel }; diff --git a/src/modules/admin/repository/admin.ts b/src/modules/admin/repository/admin.ts new file mode 100644 index 0000000..00c7c46 --- /dev/null +++ b/src/modules/admin/repository/admin.ts @@ -0,0 +1,90 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IAdmin } from "../models/Abstraction/IAdmin"; +import { AdminModel } from "../models/admin.model"; + +class AdminRepository extends BaseRepository { + constructor() { + super(AdminModel); + } + async findByPhone(phoneNumber: string) { + return this.model.findOne({ phoneNumber }); + } + async findByEmail(email: string) { + return this.model.findOne({ email }); + } + async findAdmin(email: string, phoneNumber: string) { + return this.model.findOne({ + $or: [{ email }, { phoneNumber }], + }); + } + + async getAdmins() { + const data = await this.model.aggregate([ + { + $lookup: { + from: "roles", + localField: "role", + foreignField: "_id", + as: "role", + }, + }, + { + $unwind: "$role", + }, + { + $lookup: { + from: "permissions", + localField: "permissions", + foreignField: "_id", + as: "permissions", + }, + }, + { + $lookup: { + from: "admins", + localField: "createdBy", + foreignField: "_id", + as: "createdBy", + }, + }, + { + $unwind: "$createdBy", + }, + { + $project: { + fullName: 1, + userName: 1, + email: 1, + phoneNumber: 1, + role: { + _id: "$role._id", + name: "$role.name", + createdAt: "$role.createdAt", + updatedAt: "$role.updatedAt", + }, + permissions: "$permissions.name", + createdBy: { + _id: 1, + fullName: 1, + userName: 1, + email: 1, + phoneNumber: 1, + }, + createdAt: 1, + updatedAt: 1, + }, + }, + { + $sort: { createdAt: -1 }, + }, + ]); + + return data; + } +} + +function createAdminRepository(): AdminRepository { + return new AdminRepository(); +} + +export { AdminRepository, createAdminRepository }; diff --git a/src/modules/admin/repository/contract.ts b/src/modules/admin/repository/contract.ts new file mode 100644 index 0000000..956bce9 --- /dev/null +++ b/src/modules/admin/repository/contract.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IContract } from "../models/Abstraction/IContract"; +import { ContractModel } from "../models/contract.model"; + +class ContractRepository extends BaseRepository { + constructor() { + super(ContractModel); + } +} + +function createContractRepository(): ContractRepository { + return new ContractRepository(); +} + +export { ContractRepository, createContractRepository }; diff --git a/src/modules/admin/repository/media.ts b/src/modules/admin/repository/media.ts new file mode 100644 index 0000000..7c8129b --- /dev/null +++ b/src/modules/admin/repository/media.ts @@ -0,0 +1,24 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IBanner, ISlider } from "../models/Abstraction/IMedia"; +import { BannerModel, SliderModel } from "../models/media.model"; + +class SliderRepository extends BaseRepository { + constructor() { + super(SliderModel); + } +} + +function createSliderRepo(): SliderRepository { + return new SliderRepository(); +} +class BannerRepository extends BaseRepository { + constructor() { + super(BannerModel); + } +} + +function createBannerRepo(): BannerRepository { + return new BannerRepository(); +} + +export { SliderRepository, BannerRepository, createSliderRepo, createBannerRepo }; diff --git a/src/modules/admin/repository/permission.ts b/src/modules/admin/repository/permission.ts new file mode 100644 index 0000000..720f965 --- /dev/null +++ b/src/modules/admin/repository/permission.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IPermission } from "../models/Abstraction/IPermission"; +import { PermissionModel } from "../models/permission.model"; + +class PermissionRepository extends BaseRepository { + constructor() { + super(PermissionModel); + } +} + +function createPermissionRepo(): PermissionRepository { + return new PermissionRepository(); +} + +export { PermissionRepository, createPermissionRepo }; diff --git a/src/modules/admin/repository/role.ts b/src/modules/admin/repository/role.ts new file mode 100644 index 0000000..a0e9301 --- /dev/null +++ b/src/modules/admin/repository/role.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IRole } from "../models/Abstraction/IRole"; +import { RoleModel } from "../models/role.model"; + +class RoleRepository extends BaseRepository { + constructor() { + super(RoleModel); + } +} + +function createRoleRepo(): RoleRepository { + return new RoleRepository(); +} + +export { RoleRepository, createRoleRepo }; diff --git a/src/modules/auth/DTO/Auth.dto.ts b/src/modules/auth/DTO/Auth.dto.ts new file mode 100644 index 0000000..a464e00 --- /dev/null +++ b/src/modules/auth/DTO/Auth.dto.ts @@ -0,0 +1,21 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsMobilePhone, IsNotEmpty, IsOptional } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../../common/enums/message.enum"; + +export class AuthDTO { + @Expose() + @IsOptional() + @IsNotEmpty({ message: AuthMessage.NumberNotEmpty }) + @IsMobilePhone("fa-IR", undefined, { message: AuthMessage.IncorrectNumber }) + @ApiProperty({ type: "string", description: "phone number", example: "09182532674" }) + phone?: string; + + @Expose() + @IsOptional() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email", example: "me@gmail.com" }) + email?: string; +} diff --git a/src/modules/auth/DTO/AuthCheckOtp.dto.ts b/src/modules/auth/DTO/AuthCheckOtp.dto.ts new file mode 100644 index 0000000..84406e0 --- /dev/null +++ b/src/modules/auth/DTO/AuthCheckOtp.dto.ts @@ -0,0 +1,26 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsMobilePhone, IsNotEmpty, IsOptional, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../../common/enums/message.enum"; +export class AuthCheckOtpDTO { + @Expose() + @IsOptional() + @IsNotEmpty({ message: AuthMessage.NumberNotEmpty }) + @IsMobilePhone("fa-IR", undefined, { message: AuthMessage.IncorrectNumber }) + @ApiProperty({ type: "string", description: "The phone number of the user", example: "09182532674" }) + phone?: string; + + @Expose() + @IsOptional() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email", example: "me@gmail.com" }) + email?: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.OtpNotEmpty }) + @Length(5, 5, { message: AuthMessage.OtpCodeLength }) + @ApiProperty({ type: "string", description: "The otpcode", example: "52648" }) + otpCode: string; +} diff --git a/src/modules/auth/DTO/Token.dto.ts b/src/modules/auth/DTO/Token.dto.ts new file mode 100644 index 0000000..b17dc18 --- /dev/null +++ b/src/modules/auth/DTO/Token.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../../common/enums/message.enum"; + +export class TokenDto { + @Expose() + @IsNotEmpty({ message: AuthMessage.TokenNotEmpty }) + @ApiProperty({ type: "string", description: "refresh token", example: "asd3r23wf43t54terrg45y" }) + token: string; +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..8a248b1 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,260 @@ +import { randomInt } from "crypto"; + +import Dayjs from "dayjs"; +import { inject, injectable } from "inversify"; +import { startSession } from "mongoose"; + +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UserRepository } from "../user/user.repository"; +import { AuthDTO } from "./DTO/Auth.dto"; +import { AuthCheckOtpDTO } from "./DTO/AuthCheckOtp.dto"; +import { TokenDto } from "./DTO/Token.dto"; +import { AuthMessage, CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError, UnauthorizedError } from "../../core/app/app.errors"; +import { CacheService } from "../../utils/cache.service"; +import { EmailService } from "../../utils/email.service"; +import { SMS } from "../../utils/sms.service"; +import { SellerRepository } from "../seller/seller.repository"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopService } from "../shop/shop.service"; +import { TokenRepository } from "../token/token.repository"; +import { TokenService } from "../token/token.service"; +import { ChangeEmailDTO, VerifyEmailDTO } from "../user/DTO/userUpdate.dto"; +import { WalletService } from "../wallet/wallet.service"; + +@injectable() +class AuthService { + // private logger = new Logger(AuthService.name) + @inject(IOCTYPES.TokenService) private tokenService: TokenService; + @inject(IOCTYPES.TokenRepository) private tokenRepository: TokenRepository; + @inject(IOCTYPES.UserRepository) userRepository: UserRepository; + @inject(IOCTYPES.SellerRepository) sellerRepository: SellerRepository; + @inject(IOCTYPES.CacheService) private cache: CacheService; + @inject(IOCTYPES.ShopService) private shopService: ShopService; + @inject(IOCTYPES.WalletService) walletService: WalletService; + + //######################################################## + //######################################################## + async authenticate(authDto: AuthDTO, type: string) { + const { phone, email } = authDto; + + if (!phone && !email) throw new BadRequestError(AuthMessage.EmailOrPhone); + + const identifier = phone || email!, + isPhone = !!phone, + field = isPhone ? "phoneNumber" : "email"; + + const existUser = await this.getExistUser(type, field, identifier); + + // Check if OTP already exists in cache + const existOtp = await this.cache.getAsync(`${identifier}:Login:Otp`); + + if (existOtp) { + const ttl = await this.cache.getTtlAsync(`${identifier}:Login:Otp`); + return { + message: isPhone ? AuthMessage.OtpAlreadySentToNo : AuthMessage.OtpAlreadySentToEmail, + hasAccount: !!existUser, + ttl: ttl ? ttl / 1000 : 0, // Convert milliseconds to seconds + identifier, + }; + } + + const otpCode = this.generateOtpCode(); + + //=================> + // Send OTP code + if (isPhone) { + await SMS.sendSmsVerifyCode(phone, otpCode); + } else { + await EmailService.sendVerifyEmail(email!, otpCode); + } + //<================== + + await this.saveOtpCodeInCache(otpCode, identifier); + + const ttl = await this.cache.getTtlAsync(`${identifier}:Login:Otp`); + + return { + message: isPhone ? AuthMessage.OtpSentToNo : AuthMessage.OtpSentToEmail, + hasAccount: !!existUser, + ttl: ttl ? ttl / 1000 : 120, // Convert milliseconds to seconds, default to 120 + identifier, + }; + } + + //######################################################## + //######################################################## + async loginOtpS(loginDto: AuthCheckOtpDTO, type: string) { + const { phone, otpCode, email } = loginDto; + if (!phone && !email) throw new BadRequestError(AuthMessage.EmailOrPhone); + const identifier = phone || email!; + + // Verify OTP + await this.verifyOtp(identifier, otpCode); + const Info = type === "user" ? await this.processUserLogin(identifier) : await this.processSellerLogin(identifier); + + // Generate tokens + const tokens = await this.tokenService.generateAuthTokens({ sub: Info._id.toString() }); + + return { + tokenType: "Bearer", + ...tokens, + message: AuthMessage.SuccessLogin, + Info, + }; + } + + //######################################################## + //######################################################## + async logoutS(id: string) { + const token = await this.tokenService.revokeTokens(id); + if (!token) throw new BadRequestError(AuthMessage.NeedLogin); + + return { + message: AuthMessage.LoggedOut, + }; + } + //######################################################## + //######################################################## + public async refreshTokensS(data: TokenDto) { + const refTokens = await this.tokenRepository.findByToken(data.token); + + if (!refTokens) throw new BadRequestError(AuthMessage.TokenNotFound); + + // let userInfo; + // if (data.type === process.env.USER_TYPE) { + // userInfo = await this.userRepository.findById(refTokens?.userId.toString()); + // } else if (data.type === process.env.SELLER_TYPE) { + // userInfo = await this.sellerRepository.findById(refTokens?.userId.toString()); + // } + // if (!userInfo) { + // //delete token because there is no user for that like casacade + // await this.tokenRepository.delete(refTokens._id.toString()); + // throw new NotFoundError(AuthMessage.TokenNotAssigned); + // } + + if (Dayjs(refTokens.refreshTokenExpire).isBefore(Dayjs())) { + await this.tokenRepository.delete(refTokens._id.toString()); + throw new UnauthorizedError(AuthMessage.TokenExpired); + } + + const { accessToken, refreshToken } = await this.tokenService.generateAuthTokens({ sub: refTokens.userId.toString() }); + + return { + tokenType: "Bearer", + accessToken, + refreshToken, + }; + } + //######################################################## + //######################################################## + async verifyUserEmail(verifyEmailDto: VerifyEmailDTO, userId: string) { + const { email, otpCode } = verifyEmailDto; + await this.verifyOtp(email, otpCode, "verify"); + + const updatedUser = await this.userRepository.model.findByIdAndUpdate(userId, { email }, { new: true }); + if (!updatedUser) throw new BadRequestError(CommonMessage.NotValid); + return { + message: CommonMessage.Updated, + updatedUser, + }; + } + //######################################################## + //######################################################## + async changeUserEmail(changeEmailDto: ChangeEmailDTO, userId: string) { + const existOtp = await this.cache.getAsync(`${changeEmailDto.email}:verify:Otp`); + if (existOtp) return { message: AuthMessage.OtpAlreadySentToEmail }; + + const existUser = await this.userRepository.model.exists({ email: changeEmailDto.email, _id: { $ne: userId } }); + if (existUser) throw new BadRequestError(AuthMessage.EmailExists); + + const otpCode = this.generateOtpCode(); + + await EmailService.verifyConnection(); + await EmailService.sendVerifyEmail(changeEmailDto.email, otpCode); + + await this.saveOtpCodeInCache(otpCode, changeEmailDto.email, "verify"); + + return { + message: AuthMessage.EmailSent, + email: changeEmailDto.email, + }; + } + + //helpers + //############################ + private async saveOtpCodeInCache(otpCode: string, identifier: string, cacheKey: string = "Login") { + const key = `${identifier}:${cacheKey}:Otp`; + this.cache.set(key, otpCode, 120); // 120 seconds TTL + } + + //############################ + private async verifyOtp(identifier: string, otpCode: string, cacheKey: string = "Login") { + const key = `${identifier}:${cacheKey}:Otp`; + const codeInCache = await this.cache.getAsync(key); + + if (!codeInCache || otpCode !== codeInCache) { + throw new BadRequestError(AuthMessage.OtpIncorrect); + } + + // Remove OTP from cache + await this.cache.delAsync(key); + } + + //############################ + private generateOtpCode(): string { + return randomInt(10000, 99999).toString(); + } + + //############################ + private async processUserLogin(identifier: string) { + let user = (await this.userRepository.findUserByPhone(identifier)) || (await this.userRepository.findUserByEmail(identifier)); + const userName = `U-${Date.now().toString().slice(2)}`; + if (!user) { + const userData = identifier.includes("@") + ? { email: identifier, fullName: userName } + : { phoneNumber: identifier, fullName: userName }; + + user = await this.userRepository.model.create(userData); + } + + return user; + } + + //############################ + private async processSellerLogin(identifier: string) { + const session = await startSession(); + session.startTransaction(); + try { + let seller = (await this.sellerRepository.findByPhone(identifier)) || (await this.sellerRepository.findByEmail(identifier)); + const userName = `U-${Date.now().toString().slice(2)}`; + + if (!seller) { + const sellerData = identifier.includes("@") + ? { email: identifier, fullName: userName } + : { phoneNumber: identifier, fullName: userName }; + + seller = (await this.sellerRepository.model.create([{ ...sellerData }], { session }))[0]; + await this.shopService.createDefaultShop(seller._id.toString(), OwnerRef.SELLER, session); + await this.walletService.createSellerWallet(seller._id.toString(), session); + } + + await session.commitTransaction(); + return seller; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //############################ + private async getExistUser(type: string, field: string, identifier: string) { + return type === "user" + ? await this.userRepository.model.findOne({ [field]: identifier }) + : await this.sellerRepository.model.findOne({ [field]: identifier }); + } +} + +export { AuthService }; diff --git a/src/modules/blog/DTO/blog.dto.ts b/src/modules/blog/DTO/blog.dto.ts new file mode 100644 index 0000000..224a90a --- /dev/null +++ b/src/modules/blog/DTO/blog.dto.ts @@ -0,0 +1,69 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; + +import { TimeService } from "../../../utils/time.service"; +import { IBlog } from "../models/Abstraction/IBlog"; + +export class BlogAuthorDTO { + @Expose() + fullName: string; +} +export class BlogCategoryDTO { + @Expose() + _id: string; + + @Expose() + title_fa: string; + + @Expose() + slug: string; + + @Expose() + description: string; +} + +export class BlogDTO { + @Expose() + _id: string; + + @Expose() + title_fa: string; + + @Expose() + slug: string; + + @Expose() + @Type(() => BlogCategoryDTO) + category: BlogCategoryDTO; + + @Expose() + description: string; + + @Expose() + content: string; + + @Expose() + @Type(() => BlogAuthorDTO) + author: BlogAuthorDTO; + + @Expose() + view: number; + + @Expose() + tags: string; + + @Expose() + imageUrl: string; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformBlog(data: IBlog): BlogDTO { + const blogDTO = plainToClass(BlogDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return blogDTO; + } +} diff --git a/src/modules/blog/DTO/createBlog.dto.ts b/src/modules/blog/DTO/createBlog.dto.ts new file mode 100644 index 0000000..87fe58d --- /dev/null +++ b/src/modules/blog/DTO/createBlog.dto.ts @@ -0,0 +1,112 @@ +import { Expose } from "class-transformer"; +import { IsArray, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { BlogCategoryModel } from "../models/blogCategory.model"; + +export class CreateBlogDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "farsi title of blog", example: "دیابت | علائم، علل ابتلا، پیشگیری و درمان" }) + title_fa: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "english slug of blog", example: "diabetes" }) + slug: string; + + @Expose() + @IsNotEmpty() + @IsValidId(BlogCategoryModel) + @ApiProperty({ type: "string", description: "id of blog category", example: "66eda6397ac110c16462737a" }) + category: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "description of blog ", example: "دیابت | علائم، علل ابتلا، پیشگیری و درمان" }) + description: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of blog ", + example: + "دیابت بیماری است که در آن بدن نمی تواند گلوکز لازم برای تامین انرژی سلول های بدن بیمار را تامین کند. در واقع شخصی که به این بیماری مبتلا باشد سطح گلوکز در بدن او بالا میماند و هورمونی به اسم انسولین که قند موجود در بدن را به انرژی تبدیل کند نمیتواند این انتقال را انجام دهد .", + }) + content: string; + + @Expose() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "tags of blog ", example: ["diabetes", "test", "sugar"] }) + tags: string[]; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "image url of blog ", example: "https://cdn.com" }) + imageUrl: string; +} + +export class UpdateBlogDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "farsi title of blog", example: "دیابت | علائم، علل ابتلا، پیشگیری و درمان" }) + title_fa?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "english slug of blog", example: "diabetes" }) + slug?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(BlogCategoryModel) + @ApiProperty({ type: "string", description: "id of blog category", example: "66ed83563858f185c68449ff" }) + category?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "description of blog ", example: "دیابت | علائم، علل ابتلا، پیشگیری و درمان" }) + description?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of blog ", + example: + "دیابت بیماری است که در آن بدن نمی تواند گلوکز لازم برای تامین انرژی سلول های بدن بیمار را تامین کند. در واقع شخصی که به این بیماری مبتلا باشد سطح گلوکز در بدن او بالا میماند و هورمونی به اسم انسولین که قند موجود در بدن را به انرژی تبدیل کند نمیتواند این انتقال را انجام دهد .", + }) + content?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "tags of blog ", example: ["diabetes", "test", "sugar"] }) + tags?: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "image url of blog ", example: "https://cdn.com" }) + imageUrl?: string; +} diff --git a/src/modules/blog/DTO/createBlogCategory.dto.ts b/src/modules/blog/DTO/createBlogCategory.dto.ts new file mode 100644 index 0000000..f07a669 --- /dev/null +++ b/src/modules/blog/DTO/createBlogCategory.dto.ts @@ -0,0 +1,63 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { BlogCategoryModel } from "../models/blogCategory.model"; + +export class CreateBlogCategoryDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the farsi title of blog category", example: "سلامت" }) + title_fa: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the slug of blog category", example: "healthy" }) + slug: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the description of blog category", example: "سلامت" }) + description: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(BlogCategoryModel) + @ApiProperty({ type: "string", description: "the id of parent blog category", example: "6103e3a9f9b1b5b27cd7c8e9" }) + parent?: string; +} + +export class UpdateBlogCategoryDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the farsi title of blog category", example: "سلامت" }) + title_fa?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the slug of blog category", example: "healthy" }) + slug?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the description of blog category", example: "سلامت" }) + description?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(BlogCategoryModel) + @ApiProperty({ type: "string", description: "the id of parent blog category", example: "6103e3a9f9b1b5b27cd7c8e9" }) + parent?: string; +} diff --git a/src/modules/blog/blog.controller.ts b/src/modules/blog/blog.controller.ts new file mode 100644 index 0000000..6f2c989 --- /dev/null +++ b/src/modules/blog/blog.controller.ts @@ -0,0 +1,68 @@ +import { inject } from "inversify"; +import { controller, httpGet, queryParam, requestParam } from "inversify-express-utils"; + +import { BlogService } from "./blog.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/blogs") +@ApiTags("Blog") +class BlogController extends BaseController { + @inject(IOCTYPES.BlogService) blogService: BlogService; + + @ApiOperation("get all blogs") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("page", "the page number") + @ApiQuery("limit", "the limit of returned data") + @httpGet("") + public async getAllBlogs(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.blogService.getAllBlogs(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, blogs: data.blogs }); + } + + @ApiOperation("get blog categories") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/categories") + public async getBlogCategories() { + const data = await this.blogService.getBlogCategories(); + return this.response(data); + } + + @ApiOperation("get blogs with category slug") + @ApiParam("slug", "the slug of category", true) + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/category/:slug") + public async getBlogsWithCategory(@requestParam("slug") slug: string) { + const data = await this.blogService.getBlogsByCategorySlug(slug); + return this.response(data); + } + + @ApiOperation("get blog by id") + @ApiResponse("successful", HttpStatus.Ok) + @ApiResponse("not found by id", HttpStatus.BadRequest) + @ApiParam("id", "the id of blog", true) + @httpGet("/:id([0-9a-fA-F]{24})") + public async getBlogById(@requestParam("id") blogId: string) { + const data = await this.blogService.getBlogById(blogId); + return this.response(data); + } + + @ApiOperation("get blog by slug") + @ApiResponse("successful", HttpStatus.Ok) + @ApiResponse("not found by slug", HttpStatus.BadRequest) + @ApiParam("slug", "the slug of blog", true) + @httpGet("/:slug") + public async getBlogBySlug(@requestParam("slug") slug: string) { + const data = await this.blogService.getBlogBySlug(slug); + return this.response(data); + } +} + +export { BlogController }; diff --git a/src/modules/blog/blog.service.ts b/src/modules/blog/blog.service.ts new file mode 100644 index 0000000..2ba492b --- /dev/null +++ b/src/modules/blog/blog.service.ts @@ -0,0 +1,176 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; +import slugify from "slugify"; + +import { BlogDTO } from "./DTO/blog.dto"; +import { CreateBlogDTO, UpdateBlogDTO } from "./DTO/createBlog.dto"; +import { CreateBlogCategoryDTO, UpdateBlogCategoryDTO } from "./DTO/createBlogCategory.dto"; +import { BlogRepository } from "./repository/blog"; +import { BlogCategoryRepo } from "./repository/blogCategory"; +import { CategoryMessage, CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class BlogService { + @inject(IOCTYPES.BlogRepository) blogRepo: BlogRepository; + @inject(IOCTYPES.BlogCategoryRepo) blogCategoryRepo: BlogCategoryRepo; + + //################################################ + //################################################ + + async createBlogCategory(createDto: CreateBlogCategoryDTO) { + createDto.slug = slugify(createDto.slug); + const existSlug = await this.blogCategoryRepo.findBySlug(createDto.slug); + + if (existSlug) throw new BadRequestError(CategoryMessage.DuplicateSlug); + //if parent exist first set parent leaf false + if (createDto.parent) { + const parentCategory = await this.blogCategoryRepo.findById(createDto.parent); + if (parentCategory && parentCategory.children && parentCategory.children.length >= 1) { + throw new BadRequestError(CategoryMessage.ParentHasChild); + } + await this.blogCategoryRepo.model.findByIdAndUpdate(createDto.parent, { leaf: false }); + } + const newCategory = await this.blogCategoryRepo.model.create({ + ...createDto, + }); + return { + message: CommonMessage.Created, + newCategory, + }; + } + + async deleteBlogCategory(catId: string) { + if (!isValidObjectId(catId)) throw new BadRequestError(CommonMessage.NotValidId); + + const blogCategory = await this.blogCategoryRepo.findById(catId); + if (!blogCategory) throw new BadRequestError(CommonMessage.NotValidId); + + await this.blogCategoryRepo.model.findOneAndDelete({ _id: catId }); + + return { + message: CommonMessage.Deleted, + }; + } + //################################################ + //################################################ + + async updateBlogCategory(catId: string, updateDto: UpdateBlogCategoryDTO) { + if (!isValidObjectId(catId)) throw new BadRequestError(CommonMessage.NotValidId); + + const blogCategory = await this.blogCategoryRepo.findById(catId); + if (!blogCategory) throw new BadRequestError(CommonMessage.NotValidId); + + if (updateDto.slug) { + updateDto.slug = slugify(updateDto.slug); + const existTitle = await this.blogCategoryRepo.findBySlug(updateDto.slug); + if (existTitle) throw new BadRequestError(CategoryMessage.DuplicateSlug); + } + const UpdatedCategory = await this.blogCategoryRepo.model.findByIdAndUpdate(catId, updateDto, { new: true }); + return { + message: CommonMessage.Updated, + UpdatedCategory, + }; + } + //################################################ + //################################################ + async createBlog(adminId: string, createDto: CreateBlogDTO) { + createDto.slug = slugify(createDto.slug); + const existSlug = await this.blogRepo.findBySlug(createDto.slug); + + if (existSlug) throw new BadRequestError(CategoryMessage.DuplicateSlug); + + const newBlog = await this.blogRepo.model.create({ + author: adminId, + ...createDto, + }); + + return { + message: CommonMessage.Created, + newBlog, + }; + } + //################################################ + //################################################ + async updateBlog(blogId: string, updateDto: UpdateBlogDTO) { + await this.validateBlog(blogId); + if (updateDto.slug) { + updateDto.slug = slugify(updateDto.slug); + const existTitle = await this.blogRepo.findBySlug(updateDto.slug); + if (existTitle) throw new BadRequestError(CategoryMessage.DuplicateSlug); + } + const updatedBlog = await this.blogRepo.model.findByIdAndUpdate(blogId, updateDto, { new: true }); + return { + message: CommonMessage.Updated, + updatedBlog, + }; + } + //################################################ + //################################################ + async deleteBlog(blogId: string) { + await this.validateBlog(blogId); + const deletedBlog = await this.blogRepo.model.findByIdAndDelete(blogId); + return { + message: CommonMessage.Deleted, + deletedBlog, + }; + } + //################################################ + //################################################ + async getBlogById(blogId: string) { + const blog = await this.validateBlog(blogId); + blog.view += 1; + await blog.save(); + return { + blog: BlogDTO.transformBlog(blog), + }; + } + //################################################ + //################################################ + async getBlogBySlug(slug: string) { + const blog = await this.blogRepo.findBySlug(slug); + if (!blog) throw new BadRequestError(CommonMessage.NotFoundBySlug); + blog.view += 1; + await blog.save(); + return { + blog: BlogDTO.transformBlog(blog), + }; + } + //################################################ + //################################################ + async getAllBlogs(queries: { limit: number; page: number }) { + const { docs, count } = await this.blogRepo.getAllBlog(queries); + const blogs = docs.map((doc) => BlogDTO.transformBlog(doc)); + return { + blogs, + count, + }; + } + + async getBlogCategories() { + const categories = await this.blogCategoryRepo.model.find({ parent: null }); + return { categories }; + } + + async getBlogsByCategorySlug(slug: string) { + const category = await this.blogCategoryRepo.model.findOne({ slug }); + if (!category) throw new BadRequestError(CommonMessage.NotFoundBySlug); + + const blogs = await this.blogRepo.getAllWithCategoryId(category._id.toString()); + return { blogs }; + } + //################################################ + //helper methods + private async validateBlog(blogId: string) { + if (!isValidObjectId(blogId)) throw new BadRequestError(CommonMessage.NotValidId); + + const blog = await this.blogRepo.findById(blogId); + + if (!blog) throw new BadRequestError(CommonMessage.NotValidId); + + return blog; + } +} + +export { BlogService }; diff --git a/src/modules/blog/models/Abstraction/IBlog.ts b/src/modules/blog/models/Abstraction/IBlog.ts new file mode 100644 index 0000000..7f562eb --- /dev/null +++ b/src/modules/blog/models/Abstraction/IBlog.ts @@ -0,0 +1,14 @@ +import { Types } from "mongoose"; + +export interface IBlog { + title_fa: string; + slug: string; + category: Types.ObjectId; + // name: string; + description: string; + content: string; + author: Types.ObjectId; + view: number; + tags: string[]; + imageUrl: string; +} diff --git a/src/modules/blog/models/Abstraction/IBlogCategory.ts b/src/modules/blog/models/Abstraction/IBlogCategory.ts new file mode 100644 index 0000000..a5589fc --- /dev/null +++ b/src/modules/blog/models/Abstraction/IBlogCategory.ts @@ -0,0 +1,11 @@ +import { Types } from "mongoose"; + +export interface IBlogCategory { + _id: Types.ObjectId; + title_fa: string; + slug: string; + description: string; + parent: Types.ObjectId; + children: IBlogCategory[]; + leaf: boolean; +} diff --git a/src/modules/blog/models/blog.model.ts b/src/modules/blog/models/blog.model.ts new file mode 100644 index 0000000..66ef268 --- /dev/null +++ b/src/modules/blog/models/blog.model.ts @@ -0,0 +1,28 @@ +import { Schema, model } from "mongoose"; + +import { IBlog } from "./Abstraction/IBlog"; + +const BlogSchema = new Schema( + { + title_fa: { type: String, required: true }, + slug: { type: String, unique: true, required: true }, + category: { type: Schema.Types.ObjectId, ref: "BlogCategory", required: true }, + // name: { type: String, required: true }, + description: { type: String, required: true }, + content: { type: String, required: true }, + author: { type: Schema.Types.ObjectId, ref: "Admin", required: true }, + view: { type: Number, default: 0 }, + tags: { type: [String] }, + imageUrl: { type: String }, + }, + { timestamps: true, toJSON: { virtuals: true }, id: false }, +); + +BlogSchema.pre(["find", "findOne"], function (next) { + this.populate([{ path: "category" }, { path: "author", select: { _id: 1, fullName: 1 } }]); + next(); +}); + +const BlogModel = model("Blog", BlogSchema); + +export { BlogModel }; diff --git a/src/modules/blog/models/blogCategory.model.ts b/src/modules/blog/models/blogCategory.model.ts new file mode 100644 index 0000000..3404279 --- /dev/null +++ b/src/modules/blog/models/blogCategory.model.ts @@ -0,0 +1,52 @@ +import { Schema, model } from "mongoose"; + +import { IBlogCategory } from "./Abstraction/IBlogCategory"; +import { BlogModel } from "./blog.model"; + +const BlogCategorySchema = new Schema( + { + title_fa: { type: String, required: true }, + slug: { type: String, unique: true, required: true }, + description: String, + parent: { type: Schema.Types.ObjectId, ref: "BlogCategory", default: null }, + leaf: { type: Boolean, default: true }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); +BlogCategorySchema.post(["deleteOne", "findOneAndDelete"], async function (doc, next) { + await BlogModel.deleteMany({ category: doc._id }); + next(); +}); + +BlogCategorySchema.virtual("children", { + ref: "BlogCategory", + localField: "_id", + foreignField: "parent", + justOne: false, +}); + +BlogCategorySchema.pre("find", function (next) { + this.populate({ path: "children" }); + next(); +}); +BlogCategorySchema.pre("findOne", function (next) { + this.populate({ path: "children" }); + next(); +}); + +BlogCategorySchema.pre("findOneAndDelete", async function (next) { + const parent = await this.model.findOne(this.getFilter()); + if (parent) { + const childCategories = await this.model.find({ parent: parent._id }); + const categoryIds = [parent._id, ...childCategories.map((c) => c._id)]; + console.log(categoryIds); + await BlogModel.deleteMany({ category: { $in: categoryIds } }); + const deletedBlogs = await BlogCategoryModel.deleteMany({ parent: parent._id }); + console.log({ deletedBlogs }); + } + next(); +}); + +const BlogCategoryModel = model("BlogCategory", BlogCategorySchema); + +export { BlogCategoryModel }; diff --git a/src/modules/blog/repository/blog.ts b/src/modules/blog/repository/blog.ts new file mode 100644 index 0000000..04cfb45 --- /dev/null +++ b/src/modules/blog/repository/blog.ts @@ -0,0 +1,40 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IBlog } from "../models/Abstraction/IBlog"; +import { BlogModel } from "../models/blog.model"; + +class BlogRepository extends BaseRepository { + constructor() { + super(BlogModel); + } + + async getAllBlog(queries: { page: number; limit: number }) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const count = await this.model.countDocuments({}); + + const docs = await this.model.find().skip(skip).limit(limit).sort({ createdAt: -1 }); + return { docs, count }; + } + async getAllWithCategoryId(categoryId: string) { + return this.model.find({ category: categoryId }); + } + + async findById(id: string) { + return this.model.findById(id); + } + async findBySlug(slug: string) { + return this.model.findOne({ slug }); + } + + async findAll() { + return this.model.find(); + } +} + +function createBlogRepo(): BlogRepository { + return new BlogRepository(); +} + +export { BlogRepository, createBlogRepo }; diff --git a/src/modules/blog/repository/blogCategory.ts b/src/modules/blog/repository/blogCategory.ts new file mode 100644 index 0000000..4b51cec --- /dev/null +++ b/src/modules/blog/repository/blogCategory.ts @@ -0,0 +1,18 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IBlogCategory } from "../models/Abstraction/IBlogCategory"; +import { BlogCategoryModel } from "../models/blogCategory.model"; + +class BlogCategoryRepo extends BaseRepository { + constructor() { + super(BlogCategoryModel); + } + async findBySlug(slug: string) { + return this.model.findOne({ slug }); + } +} + +function createBlogCategoryRepo(): BlogCategoryRepo { + return new BlogCategoryRepo(); +} + +export { BlogCategoryRepo, createBlogCategoryRepo }; diff --git a/src/modules/brand/DTO/brand-search.dto.ts b/src/modules/brand/DTO/brand-search.dto.ts new file mode 100644 index 0000000..b967e20 --- /dev/null +++ b/src/modules/brand/DTO/brand-search.dto.ts @@ -0,0 +1,43 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; + +export class BrandSearchQueryDTO { + @Expose() + @IsOptional() + @IsInt() + @Max(50) + limit?: number; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @Expose() + @IsOptional() + @IsBoolean() + stock?: boolean; + + @Expose() + @IsOptional() + @IsInt() + maxPrice?: number; + + @Expose() + @IsOptional() + @IsInt() + minPrice?: number; + + @Expose() + @IsOptional() + @IsBoolean() + wholeSale?: boolean; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + @Max(7) + sort?: number; +} diff --git a/src/modules/brand/DTO/brand.dto.ts b/src/modules/brand/DTO/brand.dto.ts new file mode 100644 index 0000000..60409f8 --- /dev/null +++ b/src/modules/brand/DTO/brand.dto.ts @@ -0,0 +1,35 @@ +import { Expose, plainToInstance } from "class-transformer"; + +import { IBrand } from "../models/Abstraction/IBrand"; + +export class BrandDTO { + @Expose() + _id: string; + + @Expose() + status: string; + + @Expose() + title_en: string; + + @Expose() + title_fa: string; + + @Expose() + images: string[]; + + @Expose() + logoUrl: string; + + @Expose() + website_link: string; + + public static transformBrand(data: IBrand): BrandDTO { + const transformed = plainToInstance(BrandDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return transformed; + } +} diff --git a/src/modules/brand/DTO/createBrand.dto.ts b/src/modules/brand/DTO/createBrand.dto.ts new file mode 100644 index 0000000..1480141 --- /dev/null +++ b/src/modules/brand/DTO/createBrand.dto.ts @@ -0,0 +1,53 @@ +import { OmitType } from "@nestjs/mapped-types"; +import { Expose } from "class-transformer"; +import { IsArray, IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CategoryModel } from "../../category/models/category.model"; + +export class CreateBrandDTO { + @Expose() + @IsNotEmpty() + @Length(2, 40) + @ApiProperty({ type: "string", description: "title_fa of the brand", example: "آدیتا" }) + title_fa: string; + + @Expose() + @IsNotEmpty() + @Length(2, 40) + @ApiProperty({ type: "string", description: "title_en of the brand", example: "ADATA" }) + title_en: string; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "the id of category", example: "aas55a4ee6fvw343g4fdg" }) + category: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + website_link: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "description of the brand", example: "برند آدیتا" }) + description: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "logoUrl of the brand", example: "https://cdnUrl.com" }) + logoUrl: string; + + @Expose() + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "images url of the brand", example: ["https://cdnUrl.com", "https://cdnUrl.com"] }) + images?: string[]; +} + +export class CreateGlobalBrand extends OmitType(CreateBrandDTO, ["category"] as const) {} diff --git a/src/modules/brand/DTO/findBrandQuery.dto.ts b/src/modules/brand/DTO/findBrandQuery.dto.ts new file mode 100644 index 0000000..55d7d55 --- /dev/null +++ b/src/modules/brand/DTO/findBrandQuery.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from "class-transformer"; +import { IsOptional, IsString } from "class-validator"; + +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +export class FindBrandQueryDTO extends PaginationDTO { + @Expose() + @IsOptional() + categoryId: string; + + @Expose() + @IsOptional() + status: StatusEnum; + + @Expose() + @IsOptional() + @IsString() + q: string; +} diff --git a/src/modules/brand/DTO/updateBrand.dto.ts b/src/modules/brand/DTO/updateBrand.dto.ts new file mode 100644 index 0000000..ab08dcb --- /dev/null +++ b/src/modules/brand/DTO/updateBrand.dto.ts @@ -0,0 +1,42 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CategoryModel } from "../../category/models/category.model"; + +export class UpdateBrandDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @Length(2, 40) + @ApiProperty({ type: "string", description: "title_fa of the brand", example: "آدیتا" }) + title_fa?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @Length(2, 40) + @ApiProperty({ type: "string", description: "title_en of the brand", example: "ADATA" }) + title_en?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "the id of category", example: "aas55a4ee6fvw343g4fdg" }) + category?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "description of the brand", example: "برند آدیتا" }) + description?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "logoUrl of the brand", example: "https://cdnUrl.com" }) + logoUrl?: string; +} diff --git a/src/modules/brand/brand.controller.ts b/src/modules/brand/brand.controller.ts new file mode 100644 index 0000000..e71b0b0 --- /dev/null +++ b/src/modules/brand/brand.controller.ts @@ -0,0 +1,93 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody, requestParam } from "inversify-express-utils"; + +import { BrandService } from "./brand.service"; +import { CreateBrandDTO } from "./DTO/createBrand.dto"; +import { HttpStatus } from "../../common"; +import { BrandSearchQueryDTO } from "./DTO/brand-search.dto"; +import { FindBrandQueryDTO } from "./DTO/findBrandQuery.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/brand") +@ApiTags("Brand") +class BrandController extends BaseController { + @inject(IOCTYPES.BrandService) brandService: BrandService; + + @ApiOperation("request to create a new brand ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(CreateBrandDTO) + @ApiAuth() + @httpPost("", rateLimit(appConfig.rate), Guard.authSeller(), ValidationMiddleware.validateInput(CreateBrandDTO)) + public async createBrand(@requestBody() createBrandDto: CreateBrandDTO) { + const data = await this.brandService.createBrand(createBrandDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("get products of a brand") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("name", "name of brand", true) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("stock", "search based on product stock") + @ApiQuery("maxPrice", "search based on product priceRange") + @ApiQuery("minPrice", "search based on product priceRange") + @ApiQuery("wholeSale", "search based on product wholeSale if its enable") + @ApiQuery("sort", "sort option should be int from 1 to 7") + @httpGet("/:name/search", Guard.authOptional(), ValidationMiddleware.validateQuery(BrandSearchQueryDTO)) + @ApiAuth() + public async getProductsOfBrand( + @requestParam("name") name: string, + @queryParam() searchQueryDto: BrandSearchQueryDTO, + @request() req: Request, + ) { + const user = req.user as IUser; + const { count, ...data } = await this.brandService.getProductsOfBrand(name, searchQueryDto, user?._id?.toString()); + const { pager } = this.paginate(count); + return this.response({ ...data, pager }); + } + + @ApiOperation("get a list of brands") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("categoryId", "category id of brand") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("status", "the status of brand ==> value Approved | Pending") + @ApiQuery("q", "search by brand title_en") + @httpGet("", ValidationMiddleware.validateQuery(FindBrandQueryDTO)) + public async getAll(@queryParam() queryDto: FindBrandQueryDTO) { + const data = await this.brandService.getAllS(queryDto); + const { pager } = this.paginate(data.count); + return this.response({ brands: data.docs, pager }); + } + + //uploader + @ApiOperation("Upload a brand info image ==> need to login as seller") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload", Guard.authSeller(), UploadService.single("image", "brand-images")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { BrandController }; diff --git a/src/modules/brand/brand.repository.ts b/src/modules/brand/brand.repository.ts new file mode 100644 index 0000000..6a2a743 --- /dev/null +++ b/src/modules/brand/brand.repository.ts @@ -0,0 +1,90 @@ +import { Types } from "mongoose"; + +import { FindBrandQueryDTO } from "./DTO/findBrandQuery.dto"; +import { IBrand } from "./models/Abstraction/IBrand"; +import { BrandModel } from "./models/brand.model"; +import { BaseRepository } from "../../common/base/repository"; +import { StatusEnum } from "../../common/enums/status.enum"; +import { paginationUtils } from "../../utils/pagination.utils"; + +class BrandRepository extends BaseRepository { + constructor() { + super(BrandModel); + } + + async getAllBrand(queryDto: FindBrandQueryDTO, hierarchy?: Types.ObjectId[]) { + const { skip, limit } = paginationUtils(queryDto); + + const matchStage: { [x: string]: unknown } = { + deleted: false, + }; + + if (hierarchy && hierarchy.length) { + matchStage["$or"] = [{ category: { $in: hierarchy } }, { category: null }]; + } + + if (queryDto.status) { + matchStage.status = queryDto.status; + } + if (queryDto.q) { + matchStage.$or = [{ title_fa: { $regex: queryDto.q, $options: "i" } }, { title_en: { $regex: queryDto.q, $options: "i" } }]; + } + + const docs = await this.model.aggregate([ + { + $match: matchStage, + }, + { + $lookup: { + from: "categories", + let: { categoryId: "$category" }, + pipeline: [{ $match: { $expr: { $eq: ["$_id", "$$categoryId"] } } }, { $project: { title_fa: 1, name: 1 } }], + as: "categoryDetails", + }, + }, + { + $addFields: { + category: { + $cond: { + if: { $gt: [{ $size: "$categoryDetails" }, 0] }, + then: { name: { $arrayElemAt: ["$categoryDetails.title_fa", 0] }, _id: "$category" }, + else: null, + }, + }, + }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + { + $project: { + __v: 0, + categoryDetails: 0, + createdAt: 0, + updatedAt: 0, + }, + }, + ]); + const count = await this.model.countDocuments(matchStage); + return { + docs, + count, + }; + } + + async findWithName(name: string) { + return this.model.findOne({ title_en: name }, { _id: 1, title_en: 1, title_fa: 1, status: 1, image: 1, category: 1 }); + } + + async getPendingBrandsCount() { + return this.model.countDocuments({ status: StatusEnum.Pending }); + } +} +function createBrandRepository(): BrandRepository { + return new BrandRepository(); +} + +export { BrandRepository, createBrandRepository }; diff --git a/src/modules/brand/brand.service.ts b/src/modules/brand/brand.service.ts new file mode 100644 index 0000000..9b4a899 --- /dev/null +++ b/src/modules/brand/brand.service.ts @@ -0,0 +1,122 @@ +import { inject, injectable } from "inversify"; +import slugify from "slugify"; + +import { BrandRepository } from "./brand.repository"; +import { BrandSearchQueryDTO } from "./DTO/brand-search.dto"; +import { CreateBrandDTO, CreateGlobalBrand } from "./DTO/createBrand.dto"; +import { FindBrandQueryDTO } from "./DTO/findBrandQuery.dto"; +import { UpdateBrandDTO } from "./DTO/updateBrand.dto"; +import { BrandMessage, CategoryMessage, CommonMessage } from "../../common/enums/message.enum"; +import { StatusEnum } from "../../common/enums/status.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { CategoryRepository } from "../category/category.repository"; +import { ICategory } from "../category/models/Abstraction/ICategory"; +import { ProductDTO } from "../product/DTO/product.dto"; +import { IProduct } from "../product/models/Abstraction/IProduct"; +import { ProductRepository } from "../product/Repository/product"; + +@injectable() +class BrandService { + @inject(IOCTYPES.BrandRepository) brandRepo: BrandRepository; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.CategoryRepository) categoryRepo: CategoryRepository; + + async createBrand(createDto: CreateBrandDTO) { + createDto.title_en = slugify(createDto.title_en); + + const existBrand = await this.brandRepo.model.findOne({ title_en: createDto.title_en, category: createDto.category }); + if (existBrand) throw new BadRequestError(BrandMessage.Exist); + + const newBrand = await this.brandRepo.model.create(createDto); + return { + message: BrandMessage.Created, + newBrand, + }; + } + + //######################## + + async createGlobalBrand(createDto: CreateGlobalBrand) { + createDto.title_en = slugify(createDto.title_en); + + const existBrand = await this.brandRepo.model.findOne({ title_en: createDto.title_en }); + if (existBrand) throw new BadRequestError(BrandMessage.Exist); + + const newBrand = await this.brandRepo.model.create(createDto); + return { + message: BrandMessage.Created, + newBrand, + }; + } + + //######################## + + async getAllS(queryDto: FindBrandQueryDTO) { + let category: ICategory | null = null; + if (queryDto.categoryId) { + category = await this.categoryRepo.findById(queryDto.categoryId); + if (!category) throw new BadRequestError(CategoryMessage.NotFound); + } + const { count, docs } = await this.brandRepo.getAllBrand(queryDto, category?.hierarchy); + return { count, docs }; + } + + async deleteById(id: string) { + const deletedBrand = await this.brandRepo.model.findByIdAndUpdate(id, { deleted: true }, { new: true }); + if (!deletedBrand) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Deleted, + deletedBrand, + }; + } + + async updateById(id: string, updateDto: UpdateBrandDTO) { + if (updateDto.title_en) { + updateDto.title_en = slugify(updateDto.title_en); + const existBrand = await this.brandRepo.model.findOne({ + title_en: updateDto.title_en, + category: updateDto.category, + _id: { $ne: id }, + }); + if (existBrand) throw new BadRequestError(BrandMessage.Exist); + } + const updatedBrand = await this.brandRepo.model.findByIdAndUpdate(id, updateDto, { new: true }); + if (!updatedBrand) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedBrand, + }; + } + + async getProductsOfBrand(name: string, queries: BrandSearchQueryDTO, userId?: string) { + const brand = await this.brandRepo.findWithName(name); + if (!brand) throw new BadRequestError(BrandMessage.NotFound); + + const category = await this.categoryRepo.findById(brand.category.toString()); + const priceRange = await this.productRepo.getPriceRangeByBrand(brand._id.toString()); + + const { docs, count } = await this.productRepo.getProductsWithBrand(brand._id.toString(), queries, userId); + const products = docs.map((doc: IProduct) => ProductDTO.transformProduct(doc)); + + return { brand, products, count, filters: { priceRange }, category }; + } + + async approve(id: string) { + const updatedBrand = await this.brandRepo.model.findByIdAndUpdate(id, { status: StatusEnum.Approved }, { new: true }); + if (!updatedBrand) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedBrand, + }; + } + + async getPendingBrandsCount() { + return await this.brandRepo.getPendingBrandsCount(); + } +} + +export { BrandService }; diff --git a/src/modules/brand/models/Abstraction/IBrand.ts b/src/modules/brand/models/Abstraction/IBrand.ts new file mode 100644 index 0000000..a0b98b1 --- /dev/null +++ b/src/modules/brand/models/Abstraction/IBrand.ts @@ -0,0 +1,15 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IBrand { + title_fa: string; + title_en: string; + category: Types.ObjectId; + website_link: string; + status: StatusEnum; + description: string; + logoUrl: string; + images: string[]; + deleted: boolean; +} diff --git a/src/modules/brand/models/brand.model.ts b/src/modules/brand/models/brand.model.ts new file mode 100644 index 0000000..66e1031 --- /dev/null +++ b/src/modules/brand/models/brand.model.ts @@ -0,0 +1,36 @@ +import { Schema, model } from "mongoose"; + +import { IBrand } from "./Abstraction/IBrand"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const brandSchema = new Schema( + { + title_fa: { type: String, required: true }, + title_en: { type: String, required: true }, + website_link: { type: String }, + // category: { type: Schema.Types.ObjectId, ref: "Category", required: true }, + category: { type: Schema.Types.ObjectId, ref: "Category", default: null }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + description: String, + logoUrl: String, + images: { type: [String], default: [] }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +brandSchema.index( + { title_en: 1, category: 1 }, + { + unique: true, + partialFilterExpression: { deleted: { $eq: false } }, + }, +); + +brandSchema.virtual("url").get(function () { + return `/brand/${this.title_en}`; +}); + +const BrandModel = model("Brand", brandSchema); + +export { BrandModel }; diff --git a/src/modules/cancel/DTO/cancel.dto.ts b/src/modules/cancel/DTO/cancel.dto.ts new file mode 100644 index 0000000..8e73815 --- /dev/null +++ b/src/modules/cancel/DTO/cancel.dto.ts @@ -0,0 +1,115 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsString, Min, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { StatusEnum } from "../../../common/enums/status.enum"; +import { OrderModel } from "../../order/models/order.model"; +import { OrderItemModel } from "../../order/models/orderItem.model"; +import { CancelReasonModel } from "../models/cancelReason.model"; +// import { IReturnOrder } from "../models/Abstraction/IReturn"; + +export class CancelOrderItemsDTO { + @Expose() + @IsNotEmpty() + @IsValidId(OrderItemModel) + @IsString() + @ApiProperty({ type: "string", description: "the id of orderItem ", example: "66f7bf9a71d13496054d7c2a" }) + orderItemId: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the id of shipment item ", example: "66f7bf9a71d13496054d7c2a" }) + shipmentItemId: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @Min(1) + @ApiProperty({ type: "number", description: "the number of cancel quantity ", example: 1 }) + quantity: number; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "user comment on the cancel request", example: "محصول موجود نیست" }) + comment: string; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(CancelReasonModel) + @ApiProperty({ type: "string", description: "the id of cancelReason ", example: "66f7bf9a71d13496054d7c2a" }) + reason: string; +} +export class CancelOrderDTO { + @Expose() + _id: string; + + //TODO:fix this + @Expose() + order: Record; + + @Expose() + status: StatusEnum; + + @Expose() + total_price: number; + + @Expose() + @Transform(({ value }) => new Date(value).toISOString()) + createdAt: string; + + @Expose() + @Transform(({ value }) => new Date(value).toISOString()) + updatedAt: string; + + @Expose() + @Type(() => CancelOrderItemsDTO) + cancelOrderItems: CancelOrderItemsDTO[]; + + public static transformCancelOrder(data: any): CancelOrderDTO { + const cancelOrderDTO = plainToClass(CancelOrderDTO, data, { + enableImplicitConversion: true, + }); + return cancelOrderDTO; + } +} + +export class CreateCancelDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(OrderModel) + @ApiProperty({ type: "number", description: "the id of order ", example: 401056 }) + orderId: string; + + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CancelOrderItemsDTO) + @ApiProperty({ + type: "Array", + description: "the order item to cancel", + example: [ + { + orderItemId: "66f7bf9a71d13496054d7c2a", + shipmentItemId: "66f7bf9a71d13496054d7c2a", + quantity: 1, + comment: "جنس موجود نیست", + reason: "66f7bf9a71d13496054d7c2a", + }, + { + orderItemId: "66f7bf9a71d13496054d7c2a", + shipmentItemId: "66f7bf9a71d13496054d7c2a", + quantity: 1, + comment: "از سفارش منصرف شدم", + reason: "66f7bf9a71d13496054d7c2a", + }, + ], + }) + items: CancelOrderItemsDTO[]; +} diff --git a/src/modules/cancel/DTO/cancelReason.dto.ts b/src/modules/cancel/DTO/cancelReason.dto.ts new file mode 100644 index 0000000..695d1b6 --- /dev/null +++ b/src/modules/cancel/DTO/cancelReason.dto.ts @@ -0,0 +1,29 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { FineRuleModel } from "../../fine/models/fineRule.model"; + +export class CancelReasonDTO { + @Expose() + @IsNotEmpty() + @IsValidId(FineRuleModel) + @IsString() + @ApiProperty({ type: "string", description: "the id of Fine Rule ", example: "66f7bf9a71d13496054d7c2a" }) + fineRule: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of cancel", example: "کالا موجود نیست" }) + title: string; +} + +export class UpdateCancelReasonDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of cancel", example: "کالا موجود نیست" }) + title: string; +} diff --git a/src/modules/cancel/cancel.controller.ts b/src/modules/cancel/cancel.controller.ts new file mode 100644 index 0000000..ec259fc --- /dev/null +++ b/src/modules/cancel/cancel.controller.ts @@ -0,0 +1,84 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody } from "inversify-express-utils"; + +import { CancelService } from "./cancel.service"; +import { HttpStatus } from "../../common"; +import { CreateCancelDTO } from "./DTO/cancel.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/cancel") +@ApiTags("Cancel") +class CancelController extends BaseController { + @inject(IOCTYPES.CancelService) cancelService: CancelService; + + //return an item of order + + @ApiOperation("get list of items that user canceled ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/user", Guard.authUser()) + public async getCancelUserOrders(@request() req: Request) { + const user = req.user as IUser; + const data = await this.cancelService.getCancelOrders(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get list of items that user ordered ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/user/all", Guard.authUser()) + public async getAllCancelUserOrders(@request() req: Request) { + const user = req.user as IUser; + const data = await this.cancelService.getAllUserCancelOrders(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get list of items that seller canceled ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/seller", Guard.authSeller()) + public async getCancelSellerOrders(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.cancelService.getCancelOrders(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("cancel an item of order ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateCancelDTO) + @ApiAuth() + @httpPost("/user", Guard.authUser(), ValidationMiddleware.validateInput(CreateCancelDTO)) + public async createCancelByUser(@request() req: Request, @requestBody() cancelDto: CreateCancelDTO) { + const user = req.user as IUser; + const data = await this.cancelService.createCancelOrderByUser(user._id.toString(), cancelDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("cancel an item of order ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateCancelDTO) + @ApiAuth() + @httpPost("/seller", Guard.authSeller(), ValidationMiddleware.validateInput(CreateCancelDTO)) + public async createCancelBySeller(@request() req: Request, @requestBody() cancelDto: CreateCancelDTO) { + const seller = req.user as ISeller; + const data = await this.cancelService.createCancelOrderBySeller(seller._id.toString(), cancelDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all cancel reason") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/reasons") + public async getCancelReason() { + const data = await this.cancelService.getCancelReasons(); + return this.response(data); + } +} + +export { CancelController }; diff --git a/src/modules/cancel/cancel.repository.ts b/src/modules/cancel/cancel.repository.ts new file mode 100644 index 0000000..656e5cf --- /dev/null +++ b/src/modules/cancel/cancel.repository.ts @@ -0,0 +1,217 @@ +import { Types } from "mongoose"; + +import { ICancelOrder } from "./models/Abstraction/ICancel"; +import { ICancelOrderItem } from "./models/Abstraction/ICancelItem"; +import { ICancelReason } from "./models/Abstraction/ICancelReason"; +import { CancelOrderModel } from "./models/cancel.model"; +import { CancelOrderItemModel } from "./models/cancelItem.model"; +import { CancelReasonModel } from "./models/cancelReason.model"; +import { BaseRepository } from "../../common/base/repository"; +import { AdminCancelsQueries } from "../../common/types/query.type"; +export class CancelOrderRepo extends BaseRepository { + constructor() { + super(CancelOrderModel); + } + + async getAllCancelsForAdmin(queries: AdminCancelsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const docs = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $lookup: { + from: "sellers", + localField: "canceller", + foreignField: "_id", + as: "seller", + }, + }, + { + $addFields: { + seller: { $ifNull: ["$seller", []] }, + }, + }, + { + $lookup: { + from: "users", + localField: "canceller", + foreignField: "_id", + as: "user", + }, + }, + { + $addFields: { + user: { $ifNull: ["$user", []] }, + }, + }, + { + $addFields: { + cancellerDetails: { + $cond: { + if: { $eq: ["$cancellerRef", "Seller"] }, + then: { $arrayElemAt: ["$seller", 0] }, + else: { $arrayElemAt: ["$user", 0] }, + }, + }, + }, + }, + { + $unset: ["user", "seller"], + }, + { + $lookup: { + from: "cancelorderitems", + localField: "_id", + foreignField: "cancelOrderId", + as: "cancelOrderItems", + }, + }, + { + $unwind: "$cancelOrderItems", + }, + { + $lookup: { + from: "orderitems", + localField: "cancelOrderItems.orderItem", + foreignField: "_id", + as: "cancelOrderItems.orderItem", + }, + }, + { + $unwind: "$cancelOrderItems.orderItem", + }, + { + $lookup: { + from: "products", + localField: "cancelOrderItems.orderItem.shipmentItems.product", + foreignField: "_id", + as: "cancelOrderItems.orderItem.shipmentItems.product", + }, + }, + { + $lookup: { + from: "shops", + localField: "cancelOrderItems.orderItem.shipmentItems.product.shop", + foreignField: "_id", + as: "cancelOrderItems.orderItem.shipmentItems.product.shop", + }, + }, + { + $unwind: "$cancelOrderItems.orderItem.shipmentItems.product.shop", + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + console.dir(docs[0], { depth: null }); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getAllCancelOrdersForUser(userId: string) { + const docs = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $match: { + "order.user": new Types.ObjectId(userId), + }, + }, + { + $lookup: { + from: "cancelorderitems", + localField: "_id", + foreignField: "cancelOrderId", + as: "cancelOrderItems", + }, + }, + { + $unwind: "$cancelOrderItems", + }, + { + $lookup: { + from: "orderitems", + localField: "cancelOrderItems.orderItem", + foreignField: "_id", + as: "cancelOrderItems.orderItem", + }, + }, + { + $unwind: "$cancelOrderItems.orderItem", + }, + { + $lookup: { + from: "products", + localField: "cancelOrderItems.orderItem.shipmentItems.product", + foreignField: "_id", + as: "cancelOrderItems.orderItem.shipmentItems.product", + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + return docs; + } +} + +export class CancelOrderItemRepo extends BaseRepository { + constructor() { + super(CancelOrderItemModel); + } +} + +export class CancelReasonRepo extends BaseRepository { + constructor() { + super(CancelReasonModel); + } +} + +export function createCancelOrderRepo(): CancelOrderRepo { + return new CancelOrderRepo(); +} + +export function createCancelOrderItemRepo(): CancelOrderItemRepo { + return new CancelOrderItemRepo(); +} + +export function createCancelReasonRepo(): CancelReasonRepo { + return new CancelReasonRepo(); +} diff --git a/src/modules/cancel/cancel.service.ts b/src/modules/cancel/cancel.service.ts new file mode 100644 index 0000000..a45f669 --- /dev/null +++ b/src/modules/cancel/cancel.service.ts @@ -0,0 +1,363 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId, startSession } from "mongoose"; + +import { CancelOrderItemRepo, CancelOrderRepo, CancelReasonRepo } from "./cancel.repository"; +import { CancelOrderDTO, CreateCancelDTO } from "./DTO/cancel.dto"; +import { CancelReasonDTO } from "./DTO/cancelReason.dto"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { OrderItemsStatus } from "../../common/enums/order.enum"; +import { AdminCancelsQueries } from "../../common/types/query.type"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { OrderItemRefEnum, ReasonRefEnum } from "../fine/models/fine.model"; +import { FineRepo } from "../fine/repository/fine.repository"; +import { OrderItemRepo } from "../order/order.repository"; +import { CancellerRefEnum } from "./models/cancel.model"; +import { FineRuleRepo } from "../fine/repository/fineRule.repository"; +import { NotificationService } from "../notification/notification.service"; + +@injectable() +class CancelService { + @inject(IOCTYPES.OrderItemRepo) private orderItemRepo: OrderItemRepo; + @inject(IOCTYPES.CancelOrderRepo) private cancelOrderRepo: CancelOrderRepo; + @inject(IOCTYPES.CancelOrderItemRepo) private cancelOrderItemRepo: CancelOrderItemRepo; + @inject(IOCTYPES.CancelReasonRepo) private cancelReasonRepo: CancelReasonRepo; + @inject(IOCTYPES.FineRuleRepo) private fineRuleRepo: FineRuleRepo; + @inject(IOCTYPES.FineRepo) private fineRepo: FineRepo; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + + //####################################################### + + async getCancelOrders(userId: string) { + const cancelOrders = await this.cancelOrderRepo.model.find({ canceller: userId }).sort({ createdAt: -1 }); + return { + cancelOrders, + }; + } + + async getAllUserCancelOrders(userId: string) { + const cancelOrders = await this.cancelOrderRepo.getAllCancelOrdersForUser(userId); + return { + cancelOrders, + }; + } + + //####################################################### + async getAllCancelOrders(queries: AdminCancelsQueries) { + const { docs, count } = await this.cancelOrderRepo.getAllCancelsForAdmin(queries); + const cancelsOrders = docs.map((doc: any) => CancelOrderDTO.transformCancelOrder(doc)); + return { + cancelsOrders, + count, + }; + } + + //####################################################### + //####################################################### + async createCancelOrderByUser(userId: string, createCancelDto: CreateCancelDTO) { + const session = await startSession(); + session.startTransaction(); + try { + // create Cancel Order + let total_cancel_price = 0; + const cancelOrder = await this.cancelOrderRepo.model.create( + [ + { + cancellerRef: CancellerRefEnum.User, + canceller: userId, + order: createCancelDto.orderId, + status: OrderItemsStatus.cancelled_user, + }, + ], + { session }, + ); + + const cancelOrderDoc = cancelOrder[0]; + + // Create cancelOrderItems + for (const item of createCancelDto.items) { + const orderItem = await this.orderItemRepo.model.findById(item.orderItemId).session(session); + if (!orderItem) { + throw new BadRequestError(`OrderItem with ID ${item.orderItemId} not found.`); + } + if (orderItem.status === OrderItemsStatus.Shipped || orderItem.status === OrderItemsStatus.Delivered) { + throw new BadRequestError(`OrderItem with ID ${item.orderItemId} cannot cancel in this position`); + } + const shipmentItem = orderItem.shipmentItems.find((shipItem) => shipItem._id?.equals(item.shipmentItemId)); + if (!shipmentItem) { + throw new BadRequestError(`ShipmentItem with ID ${item.shipmentItemId} not found in OrderItem ${item.orderItemId}.`); + } + + if (item.quantity > shipmentItem.quantity - shipmentItem.cancelled_quantity! - shipmentItem.returned_quantity!) { + throw new BadRequestError(`cancel quantity for ShipmentItem ${item.shipmentItemId} exceeds the available quantity.`); + } + + total_cancel_price += item.quantity * shipmentItem.selling_price; + + await this.cancelOrderItemRepo.model.create( + [ + { + cancelOrderId: cancelOrderDoc._id, + orderItem: item.orderItemId, + shipmentItem: item.shipmentItemId, + quantity: item.quantity, + comment: item.comment, + reason: item.reason, + status: OrderItemsStatus.cancelled_user, + }, + ], + { session }, + ); + shipmentItem.cancelled_quantity = item.quantity; + orderItem.status = OrderItemsStatus.cancelled_user; + orderItem.save({ session }); + } + cancelOrderDoc.total_price = total_cancel_price; + await cancelOrderDoc.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { cancelOrder: cancelOrder[0] }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //####################################################### + //####################################################### + + async createCancelOrderBySeller(sellerId: string, createCancelDto: CreateCancelDTO) { + const session = await startSession(); + session.startTransaction(); + try { + // create Cancel Order + let total_cancel_price = 0; + const cancelOrder = await this.cancelOrderRepo.model.create( + [ + { + cancellerRef: CancellerRefEnum.Seller, + canceller: sellerId, + order: createCancelDto.orderId, + status: OrderItemsStatus.cancelled_shop, + }, + ], + { session }, + ); + + const cancelOrderDoc = cancelOrder[0]; + + // Create cancelOrderItems + for (const item of createCancelDto.items) { + const orderItem = await this.orderItemRepo.model.findById(item.orderItemId).session(session); + if (!orderItem) throw new BadRequestError(`آیتم سفارش با شناسه ${item.orderItemId} یافت نشد.`); + + if (orderItem.status === OrderItemsStatus.Shipped || orderItem.status === OrderItemsStatus.Delivered) + throw new BadRequestError(`آیتم سفارش با شناسه ${item.orderItemId} در این وضعیت قابل لغو نیست.`); + + const shipmentItem = orderItem.shipmentItems.find((shipItem) => shipItem._id?.equals(item.shipmentItemId)); + if (!shipmentItem) + throw new BadRequestError(`آیتم حمل با شناسه ${item.shipmentItemId} در آیتم سفارش ${item.orderItemId} یافت نشد.`); + + const cancelReason = await this.cancelReasonRepo.model.findById(item.reason); + if (!cancelReason) { + throw new BadRequestError(CommonMessage.NotFoundById); + } + + const cancelledQuantity = shipmentItem.cancelled_quantity || 0; + const returnedQuantity = shipmentItem.returned_quantity || 0; + if (item.quantity > shipmentItem.quantity - cancelledQuantity - returnedQuantity) { + throw new BadRequestError(`Return quantity for ShipmentItem ${item.shipmentItemId} exceeds the available quantity.`); + } + + total_cancel_price += item.quantity * shipmentItem.selling_price; + + const cancelOrderItem = await this.cancelOrderItemRepo.model.create( + [ + { + cancelOrderId: cancelOrderDoc._id, + orderItem: item.orderItemId, + shipmentItem: item.shipmentItemId, + quantity: item.quantity, + comment: item.comment, + reason: item.reason, + status: OrderItemsStatus.cancelled_shop, + }, + ], + { session }, + ); + + const fineRule = await this.fineRuleRepo.findById(cancelReason.fineRule.toString()); + if (fineRule) { + const fineAmount = (fineRule.fine_percentage * total_cancel_price) / 100; + await this.fineRepo.model.create( + [ + { + seller: sellerId, + orderItem: cancelOrderItem[0]._id, + orderItemRef: OrderItemRefEnum.CancelOrderItem, + fine_rule: fineRule._id, + reason: item.reason, + reasonRef: ReasonRefEnum.Cancel, + fine_amount: fineAmount, + }, + ], + { session }, + ); + + await this.notificationService.notifyFine(sellerId, fineAmount, session); + //TODO : decrease balance from seller wallet + } + + shipmentItem.cancelled_quantity! += item.quantity; + if (shipmentItem.cancelled_quantity === shipmentItem.quantity) orderItem.status = OrderItemsStatus.cancelled_shop; + + await orderItem.save({ session }); + } + + cancelOrderDoc.total_price = total_cancel_price; + await cancelOrderDoc.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { cancelOrder: cancelOrder[0] }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //####################################################### + //####################################################### + + async createCancelOrderByAdmin(adminId: string, createCancelDto: CreateCancelDTO) { + const session = await startSession(); + session.startTransaction(); + try { + // create Cancel Order + let total_cancel_price = 0; + const cancelOrder = await this.cancelOrderRepo.model.create( + [ + { + cancellerRef: CancellerRefEnum.Admin, + canceller: adminId, + order: createCancelDto.orderId, + status: OrderItemsStatus.cancelled_system, + }, + ], + { session }, + ); + + const cancelOrderDoc = cancelOrder[0]; + + // Create cancelOrderItems + for (const item of createCancelDto.items) { + const orderItem = await this.orderItemRepo.model.findById(item.orderItemId).session(session); + if (!orderItem) throw new BadRequestError(`آیتم سفارش با شناسه ${item.orderItemId} یافت نشد.`); + + if (orderItem.status === OrderItemsStatus.Shipped || orderItem.status === OrderItemsStatus.Delivered) + throw new BadRequestError(`آیتم سفارش با شناسه ${item.orderItemId} در این وضعیت قابل لغو نیست.`); + + const shipmentItem = orderItem.shipmentItems.find((shipItem) => shipItem._id?.equals(item.shipmentItemId)); + if (!shipmentItem) + throw new BadRequestError(`آیتم حمل با شناسه ${item.shipmentItemId} در آیتم سفارش ${item.orderItemId} یافت نشد.`); + + const cancelReason = await this.cancelReasonRepo.model.findById(item.reason); + if (!cancelReason) { + throw new BadRequestError(CommonMessage.NotFoundById); + } + + const cancelledQuantity = shipmentItem.cancelled_quantity || 0; + const returnedQuantity = shipmentItem.returned_quantity || 0; + if (item.quantity > shipmentItem.quantity - cancelledQuantity - returnedQuantity) { + throw new BadRequestError(`Return quantity for ShipmentItem ${item.shipmentItemId} exceeds the available quantity.`); + } + + total_cancel_price += item.quantity * shipmentItem.selling_price; + + await this.cancelOrderItemRepo.model.create( + [ + { + cancelOrderId: cancelOrderDoc._id, + orderItem: item.orderItemId, + shipmentItem: item.shipmentItemId, + quantity: item.quantity, + comment: item.comment, + reason: item.reason, + status: OrderItemsStatus.cancelled_shop, + }, + ], + { session }, + ); + + shipmentItem.cancelled_quantity! += item.quantity; + if (shipmentItem.cancelled_quantity === shipmentItem.quantity) orderItem.status = OrderItemsStatus.cancelled_shop; + + await orderItem.save({ session }); + } + + cancelOrderDoc.total_price = total_cancel_price; + await cancelOrderDoc.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { cancelOrder: cancelOrder[0] }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //####################################################### + //####################################################### + async getCancelReasons() { + const reasons = await this.cancelReasonRepo.model.find({ deleted: false }).populate("fineRule"); + return { reasons }; + } + //####################################################### + //####################################################### + async createCancelReason(cancelReasonDto: CancelReasonDTO) { + const fineRule = await this.fineRuleRepo.model.findById(cancelReasonDto.fineRule); + if (!fineRule) { + throw new BadRequestError(CommonMessage.NotFoundById); + } + const newReason = await this.cancelReasonRepo.model.create({ + title: cancelReasonDto.title, + fineRule: fineRule._id, + }); + return { + message: CommonMessage.Created, + newReason, + }; + } + + async deleteCancelReason(reasonId: string) { + if (!isValidObjectId(reasonId)) throw new BadRequestError(CommonMessage.NotValidId); + await this.cancelReasonRepo.model.findByIdAndUpdate(reasonId, { deleted: true }, { new: true }); + return { + message: CommonMessage.Deleted, + }; + } + //####################################################### + //####################################################### + // async updateReturnReason(reasonId: string, updateReasonDto: UpdateReasonDTO) { + // if (!isValidObjectId(reasonId)) throw new BadRequestError(CommonMessage.NotValidId); + + // const updatedReason = await this.returnReasonRep.model.findByIdAndUpdate(reasonId, updateReasonDto, { new: true }); + // if (!updatedReason) throw new BadRequestError(CommonMessage.NotFoundById); + // return { + // message: CommonMessage.Updated, + // updatedReason, + // }; + // } + //####################################################### + //####################################################### +} + +export { CancelService }; diff --git a/src/modules/cancel/models/Abstraction/ICancel.ts b/src/modules/cancel/models/Abstraction/ICancel.ts new file mode 100644 index 0000000..1cb524a --- /dev/null +++ b/src/modules/cancel/models/Abstraction/ICancel.ts @@ -0,0 +1,12 @@ +import { Types } from "mongoose"; + +import { OrderItemsStatus } from "../../../../common/enums/order.enum"; +import { CancellerRefEnum } from "../cancel.model"; + +export interface ICancelOrder { + order: number; + canceller: Types.ObjectId; + cancellerRef: CancellerRefEnum; + status: OrderItemsStatus; + total_price: number; +} diff --git a/src/modules/cancel/models/Abstraction/ICancelItem.ts b/src/modules/cancel/models/Abstraction/ICancelItem.ts new file mode 100644 index 0000000..f1806b2 --- /dev/null +++ b/src/modules/cancel/models/Abstraction/ICancelItem.ts @@ -0,0 +1,13 @@ +import { Types } from "mongoose"; + +import { OrderItemsStatus } from "../../../../common/enums/order.enum"; + +export interface ICancelOrderItem { + cancelOrderId: Types.ObjectId; + orderItem: Types.ObjectId; + shipmentItem: Types.ObjectId; + quantity: number; + comment: string; + reason: Types.ObjectId; + status: OrderItemsStatus; +} diff --git a/src/modules/cancel/models/Abstraction/ICancelReason.ts b/src/modules/cancel/models/Abstraction/ICancelReason.ts new file mode 100644 index 0000000..21c6d64 --- /dev/null +++ b/src/modules/cancel/models/Abstraction/ICancelReason.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface ICancelReason { + title: string; + fineRule: Types.ObjectId; + deleted: boolean; +} diff --git a/src/modules/cancel/models/cancel.model.ts b/src/modules/cancel/models/cancel.model.ts new file mode 100644 index 0000000..083ff80 --- /dev/null +++ b/src/modules/cancel/models/cancel.model.ts @@ -0,0 +1,24 @@ +import { Schema, model } from "mongoose"; + +import { ICancelOrder } from "./Abstraction/ICancel"; +import { OrderItemsStatus } from "../../../common/enums/order.enum"; + +export enum CancellerRefEnum { + User = "User", + Seller = "Seller", + Admin = "Admin", +} + +const CancelOrderSchema = new Schema( + { + order: { type: Number, ref: "Order", required: true }, + canceller: { type: Schema.Types.ObjectId, refPath: "cancellerRef", required: true }, + cancellerRef: { type: String, enum: CancellerRefEnum, required: true }, + status: { type: String, enum: OrderItemsStatus, default: OrderItemsStatus.cancelled_system }, + total_price: { type: Number, default: 0 }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const CancelOrderModel = model("CancelOrder", CancelOrderSchema); +export { CancelOrderModel }; diff --git a/src/modules/cancel/models/cancelItem.model.ts b/src/modules/cancel/models/cancelItem.model.ts new file mode 100644 index 0000000..ae3cbfd --- /dev/null +++ b/src/modules/cancel/models/cancelItem.model.ts @@ -0,0 +1,20 @@ +import { Schema, model } from "mongoose"; + +import { ICancelOrderItem } from "./Abstraction/ICancelItem"; +import { OrderItemsStatus } from "../../../common/enums/order.enum"; +//TODO:add a refund model to refund user after complete return +const CancelOrderItemSchema = new Schema( + { + cancelOrderId: { type: Schema.Types.ObjectId, ref: "CancelOrder", required: true }, + orderItem: { type: Schema.Types.ObjectId, ref: "OrderItem", required: true }, + shipmentItem: { type: Schema.Types.ObjectId, required: true }, + quantity: { type: Number, required: true }, + comment: { type: String, required: true }, + reason: { type: Schema.Types.ObjectId, ref: "CancelReason", required: true }, + status: { type: String, enum: OrderItemsStatus, default: OrderItemsStatus.cancelled_system }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const CancelOrderItemModel = model("CancelOrderItem", CancelOrderItemSchema); +export { CancelOrderItemModel }; diff --git a/src/modules/cancel/models/cancelReason.model.ts b/src/modules/cancel/models/cancelReason.model.ts new file mode 100644 index 0000000..f4aae13 --- /dev/null +++ b/src/modules/cancel/models/cancelReason.model.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { ICancelReason } from "./Abstraction/ICancelReason"; + +const CancelReasonSchema = new Schema( + { + title: { type: String, required: true, unique: true }, + fineRule: { type: Schema.Types.ObjectId, ref: "FineRule", required: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const CancelReasonModel = model("CancelReason", CancelReasonSchema); + +export { CancelReasonModel }; diff --git a/src/modules/cart/DTO/addToCart.dto.ts b/src/modules/cart/DTO/addToCart.dto.ts new file mode 100644 index 0000000..66c997f --- /dev/null +++ b/src/modules/cart/DTO/addToCart.dto.ts @@ -0,0 +1,28 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductVariantModel } from "../../product/models/productVariant.model"; + +export class AddToCartDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "product id", example: 100000 }) + productId: number; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "variant id of a product", example: "66c0d81ecc135bbc22fc44e3" }) + variantId: string; + + // @Expose() + // @IsNotEmpty() + // @IsNumber() + // @Min(1) + // @ApiProperty({ type: "number", description: "quantity of a product", example: 5 }) + // quantity: number; +} diff --git a/src/modules/cart/DTO/cart.dto.ts b/src/modules/cart/DTO/cart.dto.ts new file mode 100644 index 0000000..4b60085 --- /dev/null +++ b/src/modules/cart/DTO/cart.dto.ts @@ -0,0 +1,58 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { ProductDTO, ProductDetailVariantDTO } from "../../product/DTO/product.dto"; +import { UserDTO } from "../../user/DTO/user.dto"; +import { ICart } from "../models/Abstraction/ICart"; + +export class CartItemsDTO { + @Expose() + @Type(() => ProductDTO) + product: ProductDTO; + + @Expose() + @Type(() => ProductDetailVariantDTO) + variant: ProductDetailVariantDTO; + + @Expose() + quantity: number; +} + +export class CartDTO { + @Expose() + _id: string; + + @Expose() + coupon_discount: number; + + @Expose() + @Type(() => CartItemsDTO) + items: CartItemsDTO[]; + + @Expose() + items_count: number; + + @Expose() + payable_price: number; + + @Expose() + retail_price: number; + + @Expose() + total_discount: number; + + @Expose() + @Type(() => UserDTO) + user: UserDTO; + + @Expose() + isWholeSale: boolean; + + public static transformCart(data: ICart): CartDTO { + const cartDTO = plainToInstance(CartDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return cartDTO; + } +} diff --git a/src/modules/cart/DTO/removeFromCart.dto.ts b/src/modules/cart/DTO/removeFromCart.dto.ts new file mode 100644 index 0000000..25ed3c1 --- /dev/null +++ b/src/modules/cart/DTO/removeFromCart.dto.ts @@ -0,0 +1,21 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductVariantModel } from "../../product/models/productVariant.model"; + +export class RemoveFromCartDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "product id", example: 100003 }) + productId: number; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "variant id of a product", example: "66c0d81ecc135bbc22fc44e3" }) + variantId: string; +} diff --git a/src/modules/cart/DTO/updateCart.dto.ts b/src/modules/cart/DTO/updateCart.dto.ts new file mode 100644 index 0000000..0daf853 --- /dev/null +++ b/src/modules/cart/DTO/updateCart.dto.ts @@ -0,0 +1,27 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, Min } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductVariantModel } from "../../product/models/productVariant.model"; + +export class UpdateCartDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "product id", example: 100003 }) + productId: number; + + @Expose() + @IsNotEmpty() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "variant id of a product", example: "66c0d81ecc135bbc22fc44e3" }) + variantId: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @Min(1) + @ApiProperty({ type: "number", description: "new quantity of the product", example: 3 }) + quantity: number; +} diff --git a/src/modules/cart/cart.controller.ts b/src/modules/cart/cart.controller.ts new file mode 100644 index 0000000..d6b19fe --- /dev/null +++ b/src/modules/cart/cart.controller.ts @@ -0,0 +1,77 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody } from "inversify-express-utils"; + +import { CartService } from "./cart.service"; +import { HttpStatus } from "../../common"; +import { AddToCartDTO } from "./DTO/addToCart.dto"; +import { RemoveFromCartDTO } from "./DTO/removeFromCart.dto"; +import { UpdateCartDTO } from "./DTO/updateCart.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/cart") +@ApiTags("Cart") +class CartController extends BaseController { + @inject(IOCTYPES.CartService) cartService: CartService; + + @ApiOperation("get user cart") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("", Guard.authUser()) + public async getCart(@request() req: Request) { + const user = req.user as IUser; + const data = await this.cartService.getCartS(user._id.toString()); + + return this.response(data); + } + + @ApiOperation("add a product to user cart") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(AddToCartDTO) + @ApiAuth() + @httpPost("/add", Guard.authUser(), ValidationMiddleware.validateInput(AddToCartDTO)) + public async addToCart(@requestBody() addToCartDto: AddToCartDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.cartService.addToCartS(addToCartDto, user._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update the user cart") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(UpdateCartDTO) + @ApiAuth() + @httpPost("/update", Guard.authUser(), ValidationMiddleware.validateInput(UpdateCartDTO)) + public async updateCart(@requestBody() updateCartDto: UpdateCartDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.cartService.updateCartS(updateCartDto, user._id.toString()); + return this.response(data); + } + + @ApiOperation("remove a item from the user cart") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(RemoveFromCartDTO) + @ApiAuth() + @httpPost("/remove", Guard.authUser(), ValidationMiddleware.validateInput(RemoveFromCartDTO)) + public async removeFromCart(@requestBody() removeFormCartDto: RemoveFromCartDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.cartService.removeFromCartS(removeFormCartDto, user._id.toString()); + return this.response(data); + } + + @ApiOperation("remove all items from the user cart") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpPost("/clear", Guard.authUser()) + public async clearCart(@request() req: Request) { + const user = req.user as IUser; + const data = await this.cartService.clearCart(user._id.toString()); + return this.response(data); + } +} + +export { CartController }; diff --git a/src/modules/cart/cart.repository.ts b/src/modules/cart/cart.repository.ts new file mode 100644 index 0000000..8188712 --- /dev/null +++ b/src/modules/cart/cart.repository.ts @@ -0,0 +1,362 @@ +import { Types } from "mongoose"; + +import { ICart } from "./models/Abstraction/ICart"; +import { ICartShipmentItem } from "./models/Abstraction/ICartShipmentItem"; +import { CartModel } from "./models/cart.model"; +import { CartShipmentItemModel } from "./models/cartShipmentItem.model"; +import { BaseRepository } from "../../common/base/repository"; + +class CartRepository extends BaseRepository { + constructor() { + super(CartModel); + } + + async getCartWithPopulate(userId: string) { + return this.model + .findOne({ user: userId }) + .populate("user") + .populate({ + path: "items.product", + }) + .populate({ + path: "items.variant", + populate: ["color", "size", "meterage", "warranty", "shop"], + }); + } + + // async getCartWithAggregate(userId: string) { + // return this.model.aggregate([ + // { + // $match: { + // user: new Types.ObjectId(userId), + // }, + // }, + // { + // $lookup: { + // from: "users", + // localField: "user", + // foreignField: "_id", + // as: "user", + // }, + // }, + // { + // $unwind: "$user", + // }, + // { + // $unwind: "$items", + // }, + // { + // $lookup: { + // from: "products", + // localField: "items.product", + // foreignField: "_id", + // as: "productDetails", + // }, + // }, + // { + // $unwind: "$productDetails", + // }, + // { + // $lookup: { + // from: "productvariants", + // localField: "items.variant", + // foreignField: "_id", + // as: "variantDetails", + // }, + // }, + // { + // $unwind: "$variantDetails", + // }, + // { + // $lookup: { + // from: "warranties", + // localField: "variantDetails.warranty", + // foreignField: "_id", + // as: "variantDetails.warranty", + // }, + // }, + // { + // $lookup: { + // from: "colors", + // localField: "variantDetails.color", + // foreignField: "_id", + // as: "variantDetails.color", + // }, + // }, + // { + // $lookup: { + // from: "sizes", + // localField: "variantDetails.size", + // foreignField: "_id", + // as: "variantDetails.size", + // }, + // }, + // { + // $lookup: { + // from: "meterages", + // localField: "variantDetails.meterage", + // foreignField: "_id", + // as: "variantDetails.meterage", + // }, + // }, + // { + // $unwind: { + // path: "$variantDetails.warranty", + // preserveNullAndEmptyArrays: true, + // }, + // }, + // { + // $unwind: { + // path: "$variantDetails.color", + // preserveNullAndEmptyArrays: true, + // }, + // }, + // { + // $unwind: { + // path: "$variantDetails.size", + // preserveNullAndEmptyArrays: true, + // }, + // }, + // { + // $unwind: { + // path: "$variantDetails.meterage", + // preserveNullAndEmptyArrays: true, + // }, + // }, + // { + // $lookup: { + // from: "shops", + // localField: "variantDetails.shop", + // foreignField: "_id", + // as: "variantDetails.shop", + // }, + // }, + // { + // $unwind: "$variantDetails.shop", + // }, + // { + // $lookup: { + // from: "shipments", + // localField: "variantDetails.shop.shipmentMethod", + // foreignField: "_id", + // as: "variantDetails.shipmentMethod", + // }, + // }, + // { + // $project: { + // productDetails: { + // adminComments: 0, + // brand: 0, + // comments: 0, + // createdAt: 0, + // updatedAt: 0, + // step: 0, + // questions: 0, + // status: 0, + // variants: 0, + // __v: 0, + // }, + // variantDetails: { + // createdAt: 0, + // updatedAt: 0, + // // warranty: 0, + // __v: 0, + // }, + // user: { + // createdAt: 0, + // updatedAt: 0, + // __v: 0, + // }, + // }, + // }, + // { + // $group: { + // _id: "$_id", + // user: { $first: "$user" }, + // items_count: { $sum: "$items.quantity" }, // Sum all quantities in the items array + // payable_price: { + // $sum: { + // $multiply: ["$items.quantity", "$variantDetails.price.selling_price"], + // }, + // }, + // retail_price: { + // $sum: { + // $multiply: ["$items.quantity", "$variantDetails.price.retailPrice"], + // }, + // }, + // total_discount: { + // $sum: { + // $subtract: [ + // { $multiply: ["$items.quantity", "$variantDetails.price.retailPrice"] }, + // { $multiply: ["$items.quantity", "$variantDetails.price.selling_price"] }, + // ], + // }, + // }, + // coupon_discount: { $first: "$coupon_discount" }, + // items: { + // $push: { + // product: "$productDetails", + // variant: "$variantDetails", + // quantity: "$items.quantity", + // }, + // }, + // }, + // }, + + // { + // $project: { + // _id: 1, + // user: 1, + // items_count: 1, + // payable_price: { + // $subtract: ["$payable_price", { $ifNull: ["$coupon_discount", 0] }], + // }, + // retail_price: 1, + // total_discount: 1, + // coupon_discount: 1, + // items: 1, + // }, + // }, + // ]); + // } + + async getCartWithAggregate(userId: string) { + const data = await this.model.aggregate([ + { $match: { user: new Types.ObjectId(userId) } }, + { $lookup: { from: "users", localField: "user", foreignField: "_id", as: "user" } }, + { $unwind: "$user" }, + { $unwind: "$items" }, + { $lookup: { from: "products", localField: "items.product", foreignField: "_id", as: "productDetails" } }, + { $unwind: "$productDetails" }, + { $lookup: { from: "productvariants", localField: "items.variant", foreignField: "_id", as: "variantDetails" } }, + { $unwind: "$variantDetails" }, + { $lookup: { from: "warranties", localField: "variantDetails.warranty", foreignField: "_id", as: "variantDetails.warranty" } }, + { $lookup: { from: "colors", localField: "variantDetails.color", foreignField: "_id", as: "variantDetails.color" } }, + { $lookup: { from: "sizes", localField: "variantDetails.size", foreignField: "_id", as: "variantDetails.size" } }, + { $lookup: { from: "meterages", localField: "variantDetails.meterage", foreignField: "_id", as: "variantDetails.meterage" } }, + { $unwind: { path: "$variantDetails.warranty", preserveNullAndEmptyArrays: true } }, + { $unwind: { path: "$variantDetails.color", preserveNullAndEmptyArrays: true } }, + { $unwind: { path: "$variantDetails.size", preserveNullAndEmptyArrays: true } }, + { $unwind: { path: "$variantDetails.meterage", preserveNullAndEmptyArrays: true } }, + { $lookup: { from: "shops", localField: "variantDetails.shop", foreignField: "_id", as: "variantDetails.shop" } }, + { $unwind: "$variantDetails.shop" }, + { + $lookup: { + from: "shipments", + localField: "variantDetails.shop.shipmentMethod", + foreignField: "_id", + as: "variantDetails.shipmentMethod", + }, + }, + { + $project: { + productDetails: { + adminComments: 0, + brand: 0, + comments: 0, + createdAt: 0, + updatedAt: 0, + step: 0, + questions: 0, + status: 0, + variants: 0, + __v: 0, + }, + variantDetails: { createdAt: 0, updatedAt: 0, __v: 0 }, + user: { createdAt: 0, updatedAt: 0, __v: 0 }, + }, + }, + { + $addFields: { + effectiveRetailPrice: { + $cond: { + if: "$variantDetails.isWholeSale", + then: { + $let: { + vars: { + wholesaleMatch: { + $arrayElemAt: [ + { + $filter: { + input: "$variantDetails.saleFormat.wholeSale", + as: "ws", + cond: { + $and: [{ $lte: ["$$ws.from", "$items.quantity"] }, { $gte: ["$$ws.to", "$items.quantity"] }], + }, + }, + }, + 0, + ], + }, + }, + in: { $ifNull: ["$$wholesaleMatch.price", "$variantDetails.price.retailPrice"] }, + }, + }, + else: "$variantDetails.price.retailPrice", + }, + }, + }, + }, + { + $group: { + _id: "$_id", + user: { $first: "$user" }, + items_count: { $sum: "$items.quantity" }, + payable_price: { + $sum: { $multiply: ["$items.quantity", "$variantDetails.price.selling_price"] }, + }, + retail_price: { + $sum: { $multiply: ["$items.quantity", "$effectiveRetailPrice"] }, + }, + total_discount: { + $sum: { + $subtract: [ + { $multiply: ["$items.quantity", "$variantDetails.price.retailPrice"] }, + { $multiply: ["$items.quantity", "$variantDetails.price.selling_price"] }, + ], + }, + }, + coupon_discount: { $first: "$coupon_discount" }, + isWholeSale: { $first: "$variantDetails.isWholeSale" }, + items: { + $push: { + product: "$productDetails", + variant: "$variantDetails", + quantity: "$items.quantity", + }, + }, + }, + }, + { + $project: { + _id: 1, + user: 1, + items_count: 1, + payable_price: { $subtract: ["$payable_price", { $ifNull: ["$coupon_discount", 0] }] }, + retail_price: 1, + total_discount: 1, + coupon_discount: 1, + items: 1, + isWholeSale: 1, + }, + }, + ]); + return data; + } +} + +function createCartRepo(): CartRepository { + return new CartRepository(); +} +//=========================================> +class CartShipItemRepo extends BaseRepository { + constructor() { + super(CartShipmentItemModel); + } +} + +function createCartShipItemRepo(): CartShipItemRepo { + return new CartShipItemRepo(); +} + +export { createCartRepo, CartRepository, CartShipItemRepo, createCartShipItemRepo }; diff --git a/src/modules/cart/cart.service.ts b/src/modules/cart/cart.service.ts new file mode 100644 index 0000000..2484e9b --- /dev/null +++ b/src/modules/cart/cart.service.ts @@ -0,0 +1,181 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, Types, startSession } from "mongoose"; + +import { CartRepository, CartShipItemRepo } from "./cart.repository"; +import { AddToCartDTO } from "./DTO/addToCart.dto"; +import { CartDTO } from "./DTO/cart.dto"; +import { RemoveFromCartDTO } from "./DTO/removeFromCart.dto"; +import { UpdateCartDTO } from "./DTO/updateCart.dto"; +import { CartMessage, ProductMessage } from "../../common/enums/message.enum"; +import { BadRequestError, InternalError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ICart } from "./models/Abstraction/ICart"; +import { IProduct } from "../product/models/Abstraction/IProduct"; +import { ProductRepository } from "../product/Repository/product"; +import { ProductVariantRepository } from "../product/Repository/productVarinat"; + +@injectable() +class CartService { + @inject(IOCTYPES.CartRepository) cartRepo: CartRepository; + @inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository; + + async getCartS(userId: string) { + const doc = (await this.cartRepo.getCartWithAggregate(userId))[0] as ICart; + const cart = CartDTO.transformCart(doc); + const recommended = await this.getRecommendedProducts(doc); + return { cart, recommended }; + } + + async clearCart(userId: string) { + const deletedCart = await this.cartRepo.model.findOneAndDelete({ user: userId }); + if (!deletedCart) throw new BadRequestError(CartMessage.CartNotFound); + return { + message: CartMessage.ItemDeleted, + }; + } + + async addToCartS(addToCartDto: AddToCartDTO, userId: string) { + const session = await startSession(); + session.startTransaction(); + try { + const { productId, variantId } = addToCartDto; + + // Get or create cart + let cart = await this.cartRepo.model.findOne({ user: userId }); + if (!cart) { + cart = (await this.cartRepo.model.create([{ user: userId }], { session }))[0]; + } + + const existingCartItem = cart.items.find((item) => item.product === productId && item.variant.toString() === variantId); + const existingQuantity = existingCartItem ? existingCartItem.quantity : 0; + + await this.validateProduct(productId, variantId, existingQuantity + 1); + + if (existingCartItem) { + // Update quantity if item already exists in the cart + existingCartItem.quantity += 1; + } else { + // Add new item to the cart + cart.items.push({ product: productId, quantity: 1, variant: new Types.ObjectId(variantId) }); + } + + await cart.save({ session }); + + // Commit the transaction + await session.commitTransaction(); + session.endSession(); + + return { + message: CartMessage.ItemAdded, + cart: await this.getCartS(userId), + }; + } catch (error) { + // Rollback any changes made in the transaction + await session.abortTransaction(); + session.endSession(); + throw error; // Re-throw the error to be handled by the calling errorHandler + } + } + + async updateCartS(updateCartDto: UpdateCartDTO, userId: string) { + const { productId, variantId, quantity } = updateCartDto; + + const cart = await this.cartRepo.model.findOne({ user: userId }); + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + + const existItem = cart.items.find((item) => item.product === productId && item.variant.toString() === variantId.toString()); + if (!existItem) throw new BadRequestError(CartMessage.ItemNotFound); + + // validate the stock + await this.validateProduct(productId, variantId, quantity); + + // update the quantity + existItem.quantity = quantity; + await cart.save(); + + return { + message: CartMessage.CartUpdated, + cart: await this.getCartS(userId), + }; + } + + async removeFromCartS(removeFromCartDto: RemoveFromCartDTO, userId: string) { + const { productId, variantId } = removeFromCartDto; + + await this.validateProduct(productId, variantId); + + // Find the cart for the user + const cart = await this.cartRepo.model.findOne({ user: userId }); + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + + // Find the item in the cart + const itemIndex = cart.items.findIndex((item) => item.product === productId && item.variant.toString() === variantId.toString()); + if (itemIndex === -1) throw new BadRequestError(CartMessage.ItemNotFound); + + // Remove the item from the cart + cart.items.splice(itemIndex, 1); + await cart.save(); + + return { + message: CartMessage.ItemDeleted, + cart: await this.getCartS(userId), + }; + } + + async clearCartAndShipmentItems(userId: string, session: ClientSession) { + const cart = await this.cartRepo.model.findOneAndDelete({ user: userId }, { session }); + + if (cart) { + await this.cartShipItemRepo.model.deleteMany({ cart: cart._id }, { session }); + } else { + throw new InternalError(); + } + } + + async getRecommendedProducts(cart: ICart) { + if (!cart) return; + const items = cart.items as unknown as { product: IProduct }[]; + + const productIds = items.map((item) => item.product._id); + const categoryIds = items.map((item) => item.product.category.toString()); + const tags = items.flatMap((item) => item.product.tags); + + const recommendedProducts = await this.productRepo.getRecommendedProducts(productIds, categoryIds, tags); + + return recommendedProducts; + } + + ///==============================> helper methods + // Helper method to check if the product exists and if the stock is sufficient + private async validateProduct(productId: number, variantId: string, totalQuantity?: number) { + // Check if product exists + const product = await this.productRepo.findById(productId); + if (!product) throw new BadRequestError(ProductMessage.ProductNotFound); + + // Check if product variant exists + const productVariant = await this.productVariantRepo.findById(variantId); + if (!productVariant) throw new BadRequestError(ProductMessage.ProductNotFound); + + if (!product.variants.includes(productVariant._id)) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + if (productVariant.product !== product._id) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + + if (totalQuantity && totalQuantity > productVariant.price.order_limit) { + // Validate order limit + throw new BadRequestError(CartMessage.OrderLimitExceeded); + } + + if (totalQuantity && productVariant.stock < totalQuantity) { + // Validate stock + throw new BadRequestError(CartMessage.StockNotEnough); + } + + return { + product, + productVariant, + }; + } +} + +export { CartService }; diff --git a/src/modules/cart/models/Abstraction/ICart.ts b/src/modules/cart/models/Abstraction/ICart.ts new file mode 100644 index 0000000..abfab92 --- /dev/null +++ b/src/modules/cart/models/Abstraction/ICart.ts @@ -0,0 +1,18 @@ +import { Types } from "mongoose"; + +export interface ICartItem { + product: number; + variant: Types.ObjectId; + quantity: number; +} + +export interface ICart { + _id: Types.ObjectId; + user: Types.ObjectId; + items: ICartItem[]; + items_count: number; + coupon_discount: number; + payable_price: number; + retail_price: number; + total_discount: number; +} diff --git a/src/modules/cart/models/Abstraction/ICartShipmentItem.ts b/src/modules/cart/models/Abstraction/ICartShipmentItem.ts new file mode 100644 index 0000000..a2d23d8 --- /dev/null +++ b/src/modules/cart/models/Abstraction/ICartShipmentItem.ts @@ -0,0 +1,29 @@ +import { Types } from "mongoose"; + +export interface ICartShipmentItem { + _id: Types.ObjectId; + cart: Types.ObjectId; + shop: Types.ObjectId; + shipmentItems: IShipmentItems[]; + shipper: number; + totalSellingPrice: number; + totalRetailPrice: number; + totalShipmentCost: number; + totalCouponDiscount: number; + totalPaymentPrice: number; + itemsCount: number; +} + +export interface IShipmentItems { + _id?: Types.ObjectId; + product: number; + variant: Types.ObjectId; + quantity: number; + cancelled_quantity?: number; + returned_quantity?: number; + selling_price: number; + retail_price: number; + discount_percent: number; + shipmentCost: number; + isFreeShip: boolean; +} diff --git a/src/modules/cart/models/cart.model.ts b/src/modules/cart/models/cart.model.ts new file mode 100644 index 0000000..2a4a83d --- /dev/null +++ b/src/modules/cart/models/cart.model.ts @@ -0,0 +1,25 @@ +import { Schema, model } from "mongoose"; + +import { ICart, ICartItem } from "./Abstraction/ICart"; + +const CartItemSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true, index: true }, + variant: { type: Schema.Types.ObjectId, ref: "ProductVariant", required: true, index: true }, + quantity: { type: Number, required: true, default: 1, min: 1 }, + }, + { _id: false }, +); + +const CartSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, + items: { type: [CartItemSchema], default: [] }, + coupon_discount: { type: Number, default: 0 }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +const CartModel = model("Cart", CartSchema); + +export { CartModel }; diff --git a/src/modules/cart/models/cartShipmentItem.model.ts b/src/modules/cart/models/cartShipmentItem.model.ts new file mode 100644 index 0000000..6e01c43 --- /dev/null +++ b/src/modules/cart/models/cartShipmentItem.model.ts @@ -0,0 +1,39 @@ +import { Schema, model } from "mongoose"; + +import { ICartShipmentItem, IShipmentItems } from "./Abstraction/ICartShipmentItem"; + +export const ShipmentItemsSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true }, + variant: { type: Schema.Types.ObjectId, ref: "ProductVariant", required: true }, + quantity: { type: Number, required: true /*default: 1, min: 1*/ }, + cancelled_quantity: { type: Number, default: 0 }, + returned_quantity: { type: Number, default: 0 }, + selling_price: { type: Number, required: true }, + retail_price: { type: Number, required: true }, + discount_percent: { type: Number, required: true }, + shipmentCost: { type: Number, required: true }, + isFreeShip: { type: Boolean, default: false }, + }, + // { id: false }, +); + +const CartShipmentItemSchema = new Schema( + { + cart: { type: Schema.Types.ObjectId, ref: "Cart", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true, index: true }, + shipmentItems: { type: [ShipmentItemsSchema], default: [] }, + shipper: { type: Number, ref: "Shipment", required: true }, + totalSellingPrice: { type: Number, required: true }, + totalRetailPrice: { type: Number, required: true }, + totalShipmentCost: { type: Number, required: true }, + totalCouponDiscount: { type: Number, default: 0 }, + totalPaymentPrice: { type: Number, required: true }, + itemsCount: { type: Number, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const CartShipmentItemModel = model("CartShipmentItem", CartShipmentItemSchema); + +export { CartShipmentItemModel }; diff --git a/src/modules/category/DTO/CreateCategory.dto.ts b/src/modules/category/DTO/CreateCategory.dto.ts new file mode 100644 index 0000000..5827343 --- /dev/null +++ b/src/modules/category/DTO/CreateCategory.dto.ts @@ -0,0 +1,40 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CategoryModel } from "../models/category.model"; + +export class CreateCategoryDTO { + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the title_fa of category", example: "موبایل" }) + title_fa: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the title_en of category", example: "mobile" }) + title_en: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the name of icon", example: "mobile" }) + icon: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the image url of category ", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the description of category", example: " موبایل و لوازم جانبی" }) + description: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "the id of parent category", example: "6103e3a9f9b1b5b27cd7c8e9" }) + parent?: string; +} diff --git a/src/modules/category/DTO/UpdateCategory.dto.ts b/src/modules/category/DTO/UpdateCategory.dto.ts new file mode 100644 index 0000000..600109d --- /dev/null +++ b/src/modules/category/DTO/UpdateCategory.dto.ts @@ -0,0 +1,65 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { CategoryThemeEnum } from "../../../common/enums/category.enum"; +import { CategoryModel } from "../models/category.model"; + +export class UpdateCategoryDTO { + @Expose() + @IsNotEmpty() + @IsString() + @Length(24, 24) + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "cat id", example: "66f3bcaee566db722a044c62" }) + catId: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the title_fa of category", example: "موبایل" }) + title_fa: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the title_en of category", example: "mobile" }) + title_en: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the name of icon", example: "mobile" }) + icon: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the description of category", example: " موبایل و لوازم جانبی" }) + description: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the image url of category ", example: "https://cdn.com" }) + imageUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "the id of parent category", example: "6103e3a9f9b1b5b27cd7c8e9" }) + parent?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsEnum(CategoryThemeEnum) + @ApiProperty({ + type: "string", + description: "the variation theme of a category ==> should be one of Color/Size/Meterage or noColor_noSize", + example: "Color", + }) + theme: CategoryThemeEnum; +} diff --git a/src/modules/category/DTO/category-sarch.dto.ts b/src/modules/category/DTO/category-sarch.dto.ts new file mode 100644 index 0000000..b6ba26b --- /dev/null +++ b/src/modules/category/DTO/category-sarch.dto.ts @@ -0,0 +1,78 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +import { PaginationDTO } from "../../../common/dto/pagination.dto"; + +export enum categoryFetchType { + Panel = "panel", + Front = "front", +} +export class CategorySearchQueryDTO { + @Expose() + @IsOptional() + @IsInt() + @Max(50) + limit?: number; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @Expose() + @IsOptional() + @IsBoolean() + stock?: boolean; + + @Expose() + @IsOptional() + @IsInt() + maxPrice?: number; + + @Expose() + @IsOptional() + @IsInt() + minPrice?: number; + + @Expose() + @IsOptional() + @IsString() + brand?: string; + + @Expose() + @IsOptional() + @IsBoolean() + wholeSale?: boolean; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + @Max(7) + sort?: number; +} + +export class SellerCategorySearchDTO { + @Expose() + @IsOptional() + @IsString() + // @MinLength(1) + q: string; + + @Expose() + @IsOptional() + parentId: string; +} + +export class CategoryQueryDto extends PaginationDTO { + @Expose() + @IsOptional() + @IsString() + // @MinLength(1) + q: string; + + @Expose() + @IsOptional() + type: categoryFetchType; +} diff --git a/src/modules/category/DTO/category.dto.ts b/src/modules/category/DTO/category.dto.ts new file mode 100644 index 0000000..556c9da --- /dev/null +++ b/src/modules/category/DTO/category.dto.ts @@ -0,0 +1,207 @@ +import { Expose, Transform, Type, plainToInstance } from "class-transformer"; + +import { ICategory } from "../models/Abstraction/ICategory"; +import { ICategoryAttribute } from "../models/Abstraction/ICategoryAttributes"; +export class Size_MeterageDTO { + @Expose() + _id: number; + + @Expose() + value: string; +} +export class ColorDTO { + @Expose() + _id: number; + + @Expose() + name: string; + + @Expose() + hexColor: string; +} +export class VariantDTO { + @Expose() + _id: string; + + @Expose() + @Type(() => ColorDTO) + @Transform(({ value }) => (value && value.length > 0 ? value : undefined) /*, { toClassOnly: true }*/) + colors: ColorDTO[]; + + @Expose() + @Type(() => Size_MeterageDTO) + @Transform(({ value }) => (value && value.length > 0 ? value : undefined) /*, { toClassOnly: true }*/) + sizes: Size_MeterageDTO[]; + + @Expose() + @Type(() => Size_MeterageDTO) + @Transform(({ value }) => (value && value.length > 0 ? value : undefined) /*, { toClassOnly: true }*/) + meterages: Size_MeterageDTO[]; +} + +export class CategoryVariantDTO { + @Expose() + _id: string; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + theme: string; + + @Expose() + icon: string; + + @Expose() + imageUrl: string; + + @Expose() + description: string; + + @Expose() + leaf: boolean; + + @Expose() + @Type(() => VariantDTO) + categoryVariants: VariantDTO; + + public static transformCategory(data: ICategory): CategoryVariantDTO { + const CategoryDTO = plainToInstance(CategoryVariantDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, //this is for transform the value to the exact type that defined for property + }); + + return CategoryDTO; + } +} + +export class CategoryPath { + @Expose() + _id: string; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; +} + +export class CategoryTreeDTO { + @Expose() + // @Transform(({ obj }) => obj._id.toString()) + _id: string; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + icon: string; + + // @Expose() + // @Type(() => CategoryPath) + // hierarchy: CategoryPath[]; + + @Expose() + imageUrl: string; + + @Expose() + theme: string; + + @Expose() + description: string; + + @Expose() + leaf: boolean; + + @Expose() + @Type(() => CategoryPath) + path: CategoryPath[]; + + @Expose() + parent: string; // parent can be null too but class transformer convert it to null automatically + + public static transformCategory(data: ICategory): CategoryTreeDTO { + const transformed = plainToInstance(CategoryTreeDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + // transformed._id = data._id instanceof ObjectId ? data._id.toString() : data._id; + // transformed.parent = data.parent instanceof ObjectId ? data.parent.toString() : data.parent; + + return transformed; + } +} + +class AttributeValuesDTO { + @Expose() + _id: number; + @Expose() + text: string; +} +class AttributeDTO { + @Expose() + _id: number; + + @Expose() + title: string; + + @Expose() + type: string; + + @Expose() + multiple: boolean; + + @Expose() + hint: string; + + @Expose() + required: boolean; + + @Expose() + @Type(() => AttributeValuesDTO) + values: AttributeValuesDTO[]; +} + +export class CategoryAttributeDTO { + @Expose() + _id: number; + + @Expose() + category_title_fa: string; + + @Expose() + @Type(() => AttributeDTO) + attributes: AttributeDTO[]; + + public static transformAttribute(data: ICategoryAttribute): CategoryAttributeDTO { + const transformed = plainToInstance(CategoryAttributeDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + // transformed._id = data._id instanceof ObjectId ? data._id.toString() : data._id; + // transformed.parent = data.parent instanceof ObjectId ? data.parent.toString() : data.parent; + + return transformed; + } +} + +// @Transform((value) => { +// console.log(value); + +// if (!value.obj.parent) return value.obj.parent; +// return value.obj.parent.toString(); +// }) + +// CategoryDTO._id = data._id instanceof ObjectId ? data._id.toString() : data._id; +// CategoryDTO.attributes._id = data.attributes._id instanceof ObjectId ? data.attributes._id.toString() : data.attributes._id; + +// @Transform(({ obj }) => obj._id.toString()) +// _id: string; diff --git a/src/modules/category/DTO/createCategoryAtt.dto.ts b/src/modules/category/DTO/createCategoryAtt.dto.ts new file mode 100644 index 0000000..c8aefc2 --- /dev/null +++ b/src/modules/category/DTO/createCategoryAtt.dto.ts @@ -0,0 +1,58 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { categoryAttType } from "../../../common/enums/category.enum"; +class AttributeValue { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "Text of the attribute value", example: "ابریشم" }) + text: string; +} + +export class CreateCategoryAttDTO { + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "Title of the attribute", example: "جنس" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsEnum(categoryAttType) + @ApiProperty({ type: "string", description: "Type of the attribute", example: "checkbox" }) + type: categoryAttType; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "if this be true then attribute can be selected many times", example: false }) + multiple: boolean; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "hint of the attribute", example: "نوع پارچه" }) + hint: string; + + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "Specifies if the attribute is required", example: true }) + required: boolean; + + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => AttributeValue) + @ApiProperty({ + type: "Array", + description: "If the attribute allows multiple values (e.g., checkbox or select), provide the values here", + example: [{ text: "ابریشم" }, { text: "اسفنجی" }], + }) + values: AttributeValue[]; +} diff --git a/src/modules/category/DTO/createCategoryTheme.dto.ts b/src/modules/category/DTO/createCategoryTheme.dto.ts new file mode 100644 index 0000000..023eab9 --- /dev/null +++ b/src/modules/category/DTO/createCategoryTheme.dto.ts @@ -0,0 +1,83 @@ +import { PickType } from "@nestjs/mapped-types"; +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsEnum, IsHexColor, IsNotEmpty, IsOptional, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { CategoryThemeEnum } from "../../../common/enums/category.enum"; + +export class CreateColorDTO { + @Expose() + @IsNotEmpty() + name: string; + + @Expose() + @IsNotEmpty() + @IsHexColor() + hexColor: string; +} + +export class CreateSizeDTO { + @Expose() + @IsNotEmpty() + value: string; +} + +export class CreateMeterageDTO { + @Expose() + @IsNotEmpty() + value: string; +} + +export class CreateCategoryThemeDTO { + @Expose() + @IsNotEmpty() + @IsEnum(CategoryThemeEnum) + @ApiProperty({ + type: "string", + description: "the variation theme of a category ==> should be one of Color/Size/Meterage or noColor_noSize", + example: "Color", + }) + theme: CategoryThemeEnum; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateColorDTO) + @ApiProperty({ + type: "Array", + required: true, + description: "the variation colors of a category theme", + example: [{ name: "مشکی", hexColor: "#000000" }], + }) + colors?: CreateColorDTO[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateSizeDTO) + @ApiProperty({ + type: "Array", + description: "the variation sizes of a category theme", + example: [{ value: "M" }, { value: "S" }, { value: "L" }], + }) + sizes?: CreateSizeDTO[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateSizeDTO) + @ApiProperty({ + type: "Array", + description: "the variation meterage of a category theme", + example: [{ value: "10" }, { value: "20" }, { value: "50" }], + }) + meterages?: CreateMeterageDTO[]; +} + +export class UpdateCategoryVariantDTO extends PickType(CreateCategoryThemeDTO, ["colors", "sizes", "meterages"] as const) {} diff --git a/src/modules/category/category.controller.ts b/src/modules/category/category.controller.ts new file mode 100644 index 0000000..8ff230a --- /dev/null +++ b/src/modules/category/category.controller.ts @@ -0,0 +1,127 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestParam } from "inversify-express-utils"; + +import { CategoryService } from "./category.service"; +import { HttpStatus } from "../../common"; +import { CategoryQueryDto, CategorySearchQueryDTO, SellerCategorySearchDTO, categoryFetchType } from "./DTO/category-sarch.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { PermissionEnum } from "../admin/models/Abstraction/IPermission"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/category") +@ApiTags("Category") +class CategoryController extends BaseController { + @inject(IOCTYPES.CategoryService) categoryService: CategoryService; + + //####################################################### + //####################################################### + @ApiOperation("Get all categories") + @ApiResponse("get all categories", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("q", "search based on category name") + @ApiQuery("type", "specify to add pagination for category if its panel or front ==> panel | front") + @httpGet("") + public async getAll(@queryParam() queryDto: CategoryQueryDto) { + if (queryDto.type === categoryFetchType.Panel) { + const { count, category } = await this.categoryService.getCategoryForPanel(queryDto); + const { pager } = this.paginate(count); + return this.response({ category, pager }); + } + const data = await this.categoryService.getCategories(); + return this.response({ data }); + } + + @ApiOperation("Get all categories (tree like)") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("parentId", "parent id of a category") + @httpGet("/tree", ValidationMiddleware.validateQuery(SellerCategorySearchDTO)) + public async getAllCategoryTree(@queryParam() queryDto: SellerCategorySearchDTO) { + const data = await this.categoryService.getCategoriesTree(queryDto); + return this.response({ data }); + } + + @ApiOperation("Get a category with id") + @ApiResponse("get a category", HttpStatus.Ok) + @ApiParam("id", "id of the category", true) + @httpGet("/:id") + public async getById(@requestParam("id") id: string) { + const data = await this.categoryService.getCategoryById(id); + return this.response({ data }); + } + + @ApiOperation("get all variant of a category with its id ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of category", true) + @httpGet("/:id/variants") + public async getCategoryVariants(@requestParam("id") catId: string) { + const data = await this.categoryService.getCategoryVariantsS(catId); + return this.response({ data }); + } + + @ApiOperation("get all attribute of a category with its id ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of category", true) + @httpGet("/:id/attributes") + public async getCategoryAttribute(@requestParam("id") catId: string) { + const data = await this.categoryService.getCategoryAttributeS(catId); + return this.response({ data }, HttpStatus.Ok); + } + + @ApiOperation("get products of a category") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("name", "name of category", true) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("stock", "search based on product stock") + @ApiQuery("maxPrice", "search based on product priceRange") + @ApiQuery("minPrice", "search based on product priceRange") + @ApiQuery("brand", "search based on product brand id") + @ApiQuery("wholeSale", "search based on product wholeSale if its enable") + @ApiQuery("sort", "sort option should be int from 1 to 7") + @httpGet("/:name/search", Guard.authOptional(), ValidationMiddleware.validateQuery(CategorySearchQueryDTO)) + @ApiAuth() + public async getProductsOfCategory( + @requestParam("name") name: string, + @queryParam() searchQueryDto: CategorySearchQueryDTO, + @request() req: Request, + ) { + const user = req.user as IUser; + const { count, ...data } = await this.categoryService.getProductsOfCategory(name, searchQueryDto, user?._id?.toString()); + const { pager } = this.paginate(count); + return this.response({ ...data, pager }); + } + + //#########################################uploader + @ApiOperation("Upload a category image ==> need to login as admin") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost( + "/image/upload", + Guard.authAdmin(), + Guard.checkAdminPermissions([PermissionEnum.ADMIN]), + UploadService.single("image", "category-images"), + ) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { CategoryController }; diff --git a/src/modules/category/category.repository.ts b/src/modules/category/category.repository.ts new file mode 100644 index 0000000..5ada8d8 --- /dev/null +++ b/src/modules/category/category.repository.ts @@ -0,0 +1,541 @@ +import { Types } from "mongoose"; + +import { SellerCategorySearchDTO } from "./DTO/category-sarch.dto"; +import { IAttributeValue } from "./models/Abstraction/IAttributeValue"; +import { ICategory } from "./models/Abstraction/ICategory"; +import { ICategoryAttribute } from "./models/Abstraction/ICategoryAttributes"; +import { IColor } from "./models/Abstraction/IColor"; +import { IMeterage } from "./models/Abstraction/IMeterage"; +import { ISize } from "./models/Abstraction/ISize"; +import { AttributeValueModel } from "./models/attributeValue.model"; +import { CategoryModel } from "./models/category.model"; +import { CategoryAttributeModel } from "./models/CategoryAttribute.model"; +import { ColorModel } from "./models/color.model"; +import { MeterageModel } from "./models/meterage.model"; +import { SizeModel } from "./models/size.model"; +import { BaseRepository } from "../../common/base/repository"; +import { BadRequestError } from "../../core/app/app.errors"; +const { ObjectId } = Types; + +class CategoryRepository extends BaseRepository { + constructor() { + super(CategoryModel); + } + async findByTitle(title_en: string, id?: string) { + const query: Record = { title_en }; + + if (id) { + query._id = { $ne: id }; + } + return this.model.findOne(query); + } + + // async getCategoryTree(parentId: string) { + // const categories = await this.model.aggregate([ + // { + // $match: { + // parent: parentId ? new ObjectId(parentId) : null, + // deleted: false, + // }, + // }, + // ]); + + // for (const category of categories) { + // category.path = await this.getCategoryPath(category._id); + // } + // return categories; + // } + + async getCategoryTree(queryDto: SellerCategorySearchDTO) { + const matchConditions: Record = { + deleted: false, + }; + + if (queryDto.q) { + matchConditions.$or = [{ title_en: { $regex: queryDto.q, $options: "i" } }, { title_fa: { $regex: queryDto.q, $options: "i" } }]; + } else { + matchConditions.parent = queryDto.parentId ? new ObjectId(queryDto.parentId) : null; + } + + const categories = await this.model.aggregate([ + { + $match: matchConditions, + }, + { + $lookup: { + from: "categories", + foreignField: "_id", + localField: "hierarchy", + as: "path", + }, + }, + { + $project: { + path: { + icon: 0, + imageUrl: 0, + hierarchy: 0, + description: 0, + theme: 0, + variants: 0, + leaf: 0, + parent: 0, + url: 0, + children: 0, + deleted: 0, + __v: 0, + }, + children: 0, + variants: 0, + __v: 0, + }, + }, + ]); + console.dir(categories, { depth: null }); + + // for (const category of categories) { + // category.path = await this.getCategoryPath(category._id); + // } + + return categories; + } + + //####################################################### + async getCategoryTree2(): Promise { + return this.model.aggregate([ + { + $match: { parent: null }, + }, + { + $graphLookup: { + from: "categories", + startWith: "$_id", + connectFromField: "_id", + connectToField: "parent", + as: "children", + depthField: "level", + }, + }, + { + $addFields: { + // url: "/category/$title_en", + }, + }, + { + $project: { + __v: 0, + updatedAt: 0, + "children.__v": 0, + }, + }, + { + $sort: { + level: 1, + }, + }, + ]); + } + + /** get category variant */ + + async getCategoryVariants(categoryIds: string[]) { + // Step 1: Fetch all categories by IDs + const categories = await this.model + .find({ _id: { $in: categoryIds.map((id) => new Types.ObjectId(id)) } }, { _id: 1, theme: 1, variants: 1, title_en: 1 }) + .lean(); + + // Create a map to store themes and their corresponding unique variant IDs + const themeVariantsMap = new Map>(); + + // Step 2: Populate the map with themes and variant IDs + for (const category of categories) { + if (!category?.theme || !category?.variants.length) continue; + + const theme = `${category?.theme}s`.toLowerCase(); // e.g., 'colors', 'sizes', 'meterages' + + if (!themeVariantsMap.has(theme)) { + themeVariantsMap.set(theme, new Set()); + } + + // Add variant IDs to the set for the specific theme + category.variants.forEach((variantId) => { + themeVariantsMap.get(theme)?.add(variantId.toString()); + }); + } + + // Step 3: Prepare a result object to store fetched variant data + const result: Record = {}; + + // Step 4: Perform a single lookup per theme + for (const [theme, variantIdsSet] of themeVariantsMap.entries()) { + const variantIds = Array.from(variantIdsSet).map((id) => +id); + + if (variantIds.length > 0) { + let variantsData; + // Fetch the variants from the corresponding theme collection + if (theme === "colors") variantsData = await ColorModel.find({ _id: { $in: variantIds } }, { __v: 0 }).lean(); + if (theme === "sizes") variantsData = await SizeModel.find({ _id: { $in: variantIds } }, { __v: 0 }).lean(); + if (theme === "meterages") variantsData = await MeterageModel.find({ _id: { $in: variantIds } }, { __v: 0 }).lean(); + // throw new Error("theme not valid"); + + // Store the result for this theme + result[theme] = variantsData as any; + } + } + + return result; + } + + async getSingleCategoryVariant(catId: string) { + // const category = await this.model.findById(catId).populate("variants").lean(); + // if (!category) { + // throw new Error("Category not found"); + // } + // return category; + + const category = await this.model.findById(catId).lean(); + if (!category || !category.theme) throw new BadRequestError("category has no theme"); + + const theme = `${category?.theme}s`.toLowerCase(); + + // Ensure the theme is valid + const allowedThemes = ["colors", "sizes", "meterages"]; + if (!allowedThemes.includes(theme)) { + throw new BadRequestError(`category has ${theme}`); + } + + const result = await this.model.aggregate([ + { + $match: { + _id: new ObjectId(catId), + }, + }, + { + $lookup: { + from: theme, + localField: "variants", + foreignField: "_id", + as: "variantData", + }, + }, + { + $addFields: { + categoryVariants: { + [theme]: "$variantData", + }, + }, + }, + { + $project: { + variantData: 0, + variants: 0, + categoryVariants: { + [theme]: { + __v: 0, + }, + }, + }, + }, + ]); + + return result[0]; + } + + /** get category attributes */ + async getCategoryAttributes(categoryIds: string[]): Promise { + const attributes = await this.model.aggregate([ + { + $match: { + _id: { $in: categoryIds.map((id) => new Types.ObjectId(id)) }, + }, + }, + { + $lookup: { + from: "categoryattributes", + localField: "_id", + foreignField: "category", + as: "attributes", + }, + }, + { + $unwind: "$attributes", + }, + { + $lookup: { + from: "attributevalues", + localField: "attributes._id", + foreignField: "attribute", + as: "attributes.values", + }, + }, + { + $group: { + _id: null, // Use `null` to group all categories together + category_title_fa: { $addToSet: "$title_fa" }, + attributes: { + $push: { + _id: "$attributes._id", + title: "$attributes.title", + hint: "$attributes.hint", + type: "$attributes.type", + multiple: "$attributes.multiple", + required: "$attributes.required", + values: "$attributes.values", + }, + }, + }, + }, + { + $project: { + _id: 0, // Remove the grouping _id + category_title_fa: 1, + attributes: 1, + }, + }, + ]); + + return attributes.length ? attributes[0] : null; + } + + /** find with name */ + async findWithName(name: string) { + return this.model.findOne( + { title_en: name }, + { _id: 1, title_fa: 1, title_en: 1, description: 1, icon: 1, theme: 1, children: 1, url: 1 }, + ); + } + + async getCategoryPath(catId: string) { + const category = await this.model.findById(catId).lean(); + if (!category) return null; + + const path = [{ id: category._id, title_fa: category.title_fa, title_en: category.title_en }]; // Add the current category to the path + let parent = category.parent; + + while (parent) { + const parentCategory = await this.model.findById(parent).lean(); + if (!parentCategory) break; + path.unshift({ id: parentCategory._id, title_fa: parentCategory.title_fa, title_en: parentCategory.title_en }); + parent = parentCategory.parent; + } + + return path; + } +} + +function createCategoryRepo(): CategoryRepository { + return new CategoryRepository(); +} +//=====================>category att repo +class CategoryAttributeRepo extends BaseRepository { + constructor() { + super(CategoryAttributeModel); + } + + async findByCategoryId(catId: string) { + return this.model.findOne({ category: catId }); + } +} +function createCategoryAttributeRepo(): CategoryAttributeRepo { + return new CategoryAttributeRepo(); +} + +//=============================> attribute value repo +class AttributeValueRepo extends BaseRepository { + constructor() { + super(AttributeValueModel); + } + + async findByText(text: string) { + return this.model.findOne({ text }); + } +} + +function createAttributeValueRepo(): AttributeValueRepo { + return new AttributeValueRepo(); +} + +//=====================>size model repository +class SizeRepository extends BaseRepository { + constructor() { + super(SizeModel); + } +} +function createSizeRepo(): SizeRepository { + return new SizeRepository(); +} + +//=====================>color model repo +class ColorRepository extends BaseRepository { + constructor() { + super(ColorModel); + } +} +function createColorRepo(): ColorRepository { + return new ColorRepository(); +} + +//=====================>meterage model repo +class MeterageRepository extends BaseRepository { + constructor() { + super(MeterageModel); + } +} +function createMeterageRepo(): MeterageRepository { + return new MeterageRepository(); +} + +export { + createCategoryRepo, + createCategoryAttributeRepo, + createSizeRepo, + createMeterageRepo, + createColorRepo, + createAttributeValueRepo, + AttributeValueRepo, + CategoryRepository, + CategoryAttributeRepo, + SizeRepository, + ColorRepository, + MeterageRepository, +}; + +// async getCategoryVariant(catId: string) { + +// return this.model.aggregate([ +// { +// $match: { +// _id: new ObjectId(catId), +// }, +// }, +// { +// $lookup: { +// from: "colors", +// localField: "variants", +// foreignField: "_id", +// as: "colors", +// }, +// }, +// { +// $lookup: { +// from: "sizes", +// localField: "variants", +// foreignField: "_id", +// as: "sizes", +// }, +// }, +// { +// $lookup: { +// from: "meterages", +// localField: "variants", +// foreignField: "_id", +// as: "meterages", +// }, +// }, +// { +// $addFields: { +// variants: { +// $cond: { +// if: { $gt: [{ $size: "$colors" }, 0] }, +// then: { colors: "$colors" }, +// else: {}, +// }, +// }, +// }, +// }, +// { +// $addFields: { +// "variants.sizes": { +// $cond: { +// if: { $gt: [{ $size: "$sizes" }, 0] }, +// then: "$sizes", +// else: "$$REMOVE", +// }, +// }, +// "variants.meterages": { +// $cond: { +// if: { $gt: [{ $size: "$meterages" }, 0] }, +// then: "$meterages", +// else: "$$REMOVE", +// }, +// }, +// }, +// }, +// { +// $project: { +// colors: 0, +// sizes: 0, +// meterages: 0, +// }, +// }, +// ]); +// return this.model.aggregate([ +// { +// $match: { +// _id: new ObjectId(catId), +// }, +// }, +// { +// $lookup: { +// from: "colors", +// localField: "variants", +// foreignField: "_id", +// as: "colors", +// }, +// }, +// { +// $lookup: { +// from: "sizes", +// localField: "variants", +// foreignField: "_id", +// as: "sizes", +// }, +// }, +// { +// $lookup: { +// from: "meterages", +// localField: "variants", +// foreignField: "_id", +// as: "meterages", +// }, +// }, +// { +// $addFields: { +// variants: {}, +// }, +// }, +// { +// $addFields: { +// "variants.colors": "$colors", +// "variants.sizes": "$sizes", +// "variants.meterages": "$meterages", +// }, +// }, +// // { +// // $unwind: "$variants", +// // }, +// { +// $project: { +// colors: 0, +// sizes: 0, +// meterages: 0, +// // "variants.colors": { $cond: { if: { $ne: ["$variants.colors", null] }, then: "$variants.colors", else: "$$REMOVE" } }, +// // "variants.sizes": { $cond: { if: { $ne: ["$variants.sizes", null] }, then: "$variants.sizes", else: "$$REMOVE" } }, +// // "variants.meterages": { $cond: { if: { $ne: ["$variants.meterages", null] }, then: "$variants.meterages", else: "$$REMOVE" } }, +// }, +// }, +// // { +// // $addFields: { +// // "variants.colors": { +// // $cond: { if: { $eq: [{ $size: "$variants.colors" }, 0] }, then: null, else: "$variants.colors" }, +// // }, +// // "variants.sizes": { +// // $cond: { if: { $eq: [{ $size: "$variants.sizes" }, 0] }, then: null, else: "$variants.sizes" }, +// // }, +// // "variants.meterages": { +// // $cond: { if: { $eq: [{ $size: "$variants.meterages" }, 0] }, then: null, else: "$variants.meterages" }, +// // }, +// // }, +// // }, +// ]); +// } diff --git a/src/modules/category/category.service.ts b/src/modules/category/category.service.ts new file mode 100644 index 0000000..12e2154 --- /dev/null +++ b/src/modules/category/category.service.ts @@ -0,0 +1,481 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, FilterQuery, isValidObjectId, startSession } from "mongoose"; +import slugify from "slugify"; + +import { + AttributeValueRepo, + CategoryAttributeRepo, + CategoryRepository, + ColorRepository, + MeterageRepository, + SizeRepository, +} from "./category.repository"; +import { BrandRepository } from "../brand/brand.repository"; +import { CategoryQueryDto, CategorySearchQueryDTO, SellerCategorySearchDTO } from "./DTO/category-sarch.dto"; +import { CategoryAttributeDTO, CategoryTreeDTO, CategoryVariantDTO } from "./DTO/category.dto"; +import { CreateCategoryDTO } from "./DTO/CreateCategory.dto"; +import { CreateCategoryAttDTO } from "./DTO/createCategoryAtt.dto"; +import { + CreateCategoryThemeDTO, + CreateColorDTO, + CreateMeterageDTO, + CreateSizeDTO, + UpdateCategoryVariantDTO, +} from "./DTO/createCategoryTheme.dto"; +import { UpdateCategoryDTO } from "./DTO/UpdateCategory.dto"; +import { IColor } from "./models/Abstraction/IColor"; +import { IMeterage } from "./models/Abstraction/IMeterage"; +import { ISize } from "./models/Abstraction/ISize"; +import { BaseRepository } from "../../common/base/repository"; +import { CategoryThemeEnum } from "../../common/enums/category.enum"; +import { CategoryMessage, CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError, ForbiddenError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ProductDTO } from "../product/DTO/product.dto"; +import { IProduct } from "../product/models/Abstraction/IProduct"; +import { ProductRepository } from "../product/Repository/product"; +import { ICategory } from "./models/Abstraction/ICategory"; +import { paginationUtils } from "../../utils/pagination.utils"; + +@injectable() +class CategoryService { + @inject(IOCTYPES.CategoryRepository) categoryRepository: CategoryRepository; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.BrandRepository) brandRepo: BrandRepository; + @inject(IOCTYPES.CategoryAttributeRepository) categoryAttRepo: CategoryAttributeRepo; + @inject(IOCTYPES.AttributeValueRepository) attributeValueRepo: AttributeValueRepo; + @inject(IOCTYPES.ColorRepository) colorRepo: ColorRepository; + @inject(IOCTYPES.SizeRepository) sizeRepo: SizeRepository; + @inject(IOCTYPES.MeterageRepository) meterageRepo: MeterageRepository; + + async getCategories() { + return this.categoryRepository.model.find({ parent: null }); + } + //################################ + async getCategoryForPanel(queryDto: CategoryQueryDto) { + const { limit, skip } = paginationUtils(queryDto); + + const searchQuery: FilterQuery = { + parent: null, + deleted: false, + }; + + if (queryDto.q) { + searchQuery.$or = [{ title_en: { $regex: queryDto.q, $options: "i" } }, { title_fa: { $regex: queryDto.q, $options: "i" } }]; + } + const count = await this.categoryRepository.model.countDocuments(searchQuery); + const category = await this.categoryRepository.model.find(searchQuery).skip(skip).limit(limit); + return { count, category }; + } + //################################ + async getCategoriesTree(queryDto: SellerCategorySearchDTO) { + return this.categoryRepository.getCategoryTree(queryDto); + // const categories = docs.map((doc) => CategoryTreeDTO.transformCategory(doc)); + + // return categories; + } + + //################################ + + // async searchCategory(queryDto: SellerCategorySearchDTO) { + // const docs = await this.categoryRepository.searchCategory(queryDto.q); + // const categories = docs.map((doc) => CategoryTreeDTO.transformCategory(doc)); + // return { + // categories, + // }; + // } + + async getCategoryById(id: string) { + if (!isValidObjectId(id)) throw new BadRequestError(CommonMessage.NotValidId); + return this.categoryRepository.model.findOne({ _id: id, deleted: false }); + } + //################################ + + async updateCategory(updateDto: UpdateCategoryDTO) { + const category = await this.checkCategoryId(updateDto.catId); + if (updateDto.title_en) { + updateDto.title_en = slugify(updateDto.title_en); + } + + const existTitle = await this.categoryRepository.findByTitle(updateDto.title_en, category._id.toString()); + + if (existTitle) throw new BadRequestError(CategoryMessage.DuplicateTitle); + //TODO: check if parent is valid for update theme + if (updateDto.theme) { + await this.categoryRepository.model.findOneAndUpdate({ _id: category._id.toString() }, { theme: updateDto.theme, variants: [] }); + } + if (updateDto.parent) { + const updatedCategory = await this.categoryRepository.model.findByIdAndUpdate( + category._id.toString(), + { ...updateDto, leaf: false }, + { new: true }, + ); + return { + message: CategoryMessage.Updated, + updatedCategory, + }; + } + const updatedCategory = await this.categoryRepository.model.findByIdAndUpdate(category._id.toString(), updateDto, { new: true }); + return { + message: CategoryMessage.Updated, + updatedCategory, + }; + } + //################################ + + async createCategory(createDto: CreateCategoryDTO) { + createDto.title_en = slugify(createDto.title_en); + + const existTitle = await this.categoryRepository.findByTitle(createDto.title_en); + + if (existTitle) throw new BadRequestError(CategoryMessage.DuplicateTitle); + //if parent exist first set parent leaf false + if (createDto.parent) { + await this.categoryRepository.model.findByIdAndUpdate(createDto.parent, { leaf: false }); + } + //we set the leaf to true + const category = await this.categoryRepository.model.create(createDto); + + return { + message: CategoryMessage.Created, + category, + }; + } + + //################################ + async deleteCategory(categoryId: string) { + const category = await this.checkCategoryId(categoryId); + + const existChild = await this.categoryRepository.model.exists({ parent: category._id.toString() }); + if (existChild) throw new BadRequestError(CategoryMessage.CategoryIsParent); + + const productsWithCategory = await this.productRepo.model.find( + { + category: category._id.toString(), + }, + { _id: 1, title_fa: 1 }, + ); + if (productsWithCategory.length > 0) { + const productsName = productsWithCategory.map((product) => product.title_en); + throw new BadRequestError([`${CategoryMessage.CategoryHasProduct} ${productsName.join(" ")}`]); + } + + await this.categoryRepository.model.findByIdAndUpdate(category._id.toString(), { + deleted: true, + }); + return { + message: CategoryMessage.Deleted, + }; + } + + //################################ + + async updateCategoryVariantS(updateDto: UpdateCategoryVariantDTO, id: string) { + const session = await startSession(); + session.startTransaction(); + try { + const { colors, sizes, meterages } = updateDto; + + const category = await this.checkCategoryId(id); + + // Ensure the theme matches the provided attributes and create variants + const variantRefs = await this.createVariantsBasedOnTheme(session, category.theme, colors, sizes, meterages); + + // Update the category with the theme and variants + category.variants = variantRefs; + await category.save({ session }); + + await session.commitTransaction(); + + return { + message: CommonMessage.Updated, + category, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + //################################ + async createCategoryThemeS(createCategoryThemeDto: CreateCategoryThemeDTO, catId: string) { + const session = await startSession(); + session.startTransaction(); + try { + const { theme, colors, sizes, meterages } = createCategoryThemeDto; + + // Validate category ID + const category = await this.checkCategoryId(catId); + + // Check if the theme already exists + if (category.theme) throw new BadRequestError(CategoryMessage.AlreadyExist); + + // Ensure the theme matches the provided attributes and create variants + const variantRefs = await this.createVariantsBasedOnTheme(session, theme, colors, sizes, meterages); + + // Update the category with the theme and variants + category.theme = theme; + category.variants = variantRefs; + await category.save({ session }); + + // Commit the transaction + await session.commitTransaction(); + session.endSession(); + + return { + message: CategoryMessage.VariantCreated, + variant: category, + }; + } catch (error) { + // Rollback any changes made in the transaction + await session.abortTransaction(); + session.endSession(); + throw error; // Re-throw the error to be handled by the calling errorHandler + } + } + + async createCategoryAttributeS(createAttributeDto: CreateCategoryAttDTO, categoryId: string) { + const session = await startSession(); + session.startTransaction(); + try { + const { title, type, multiple, required, values, hint } = createAttributeDto; + + // Validate the existence of the category + await this.checkCategoryId(categoryId); + + // Check if attribute already exists for the category + const existAttribute = await this.categoryAttRepo.model.exists({ category: categoryId, title }); + if (existAttribute) { + throw new BadRequestError(CategoryMessage.AttAlreadyExist); + } + + // Create the category attribute + const categoryAttribute = await this.categoryAttRepo.model.create( + [ + { + category: categoryId, + title, + type, + multiple, + hint, + required, + }, + ], + { session }, + ); + + for (const value of values) { + await this.attributeValueRepo.model.create([{ text: value.text, attribute: categoryAttribute[0]._id }], { session }); + } + + // Commit the transaction + await session.commitTransaction(); + session.endSession(); + + return { + message: CategoryMessage.AttributeCreated, + attribute: categoryAttribute[0], + }; + } catch (error) { + // Rollback any changes made in the transaction + await session.abortTransaction(); + session.endSession(); + throw error; // Re-throw the error to be handled by the calling errorHandler + } + } + + async getCategoryAttributeS(catId: string) { + await this.checkCategoryId(catId); + + const docs = await this.categoryRepository.getCategoryAttributes([catId]); + if (!docs) throw new ForbiddenError(CategoryMessage.CategoryNotValidToChose); + + const attribute = CategoryAttributeDTO.transformAttribute(docs); + + return { + category: attribute, + }; + } + + async deleteCategoryAttributeS(attId: string) { + const existAttribute = await this.categoryAttRepo.model.findById(attId); + if (!existAttribute) throw new BadRequestError(CategoryMessage.NotValidId); + + await this.categoryAttRepo.model.findByIdAndDelete(attId); + + return { + message: CategoryMessage.AttributeDeleted, + }; + } + + async deleteCategoryVariantS(catId: string, varId: string) { + const category = await this.checkCategoryId(catId); + category.variants = category.variants.filter((variant) => variant.toString() !== varId); + await category.save(); + + return { + message: CategoryMessage.AttributeDeleted, + }; + } + + async getCategoryVariantsS(catId: string) { + await this.checkCategoryId(catId); + const docs = await this.categoryRepository.getSingleCategoryVariant(catId); + // const category = await this.categoryRepository.model.findById(catId).populate({ + // path: "attributes", + // populate: { + // path: "variants", + // }, + // }); + if (!docs) throw new ForbiddenError(CategoryMessage.CategoryNotValidToChose); + const category = CategoryVariantDTO.transformCategory(docs); + return { + category, + }; + } + + async getProductsOfCategory(name: string, queries: CategorySearchQueryDTO, userId?: string) { + const category = await this.categoryRepository.findWithName(name); + if (!category) throw new BadRequestError(CategoryMessage.NotFound); + + const categoryIds = await this.getAllCategoryIds(category._id.toString()); + // get related attributes, price range, and brands + const catAttributes = await this.categoryRepository.getCategoryAttributes(categoryIds); + const priceRange = await this.productRepo.getPriceRange(categoryIds); + const brands = await this.brandRepo.model.find({ category: { $in: categoryIds } }); + + // get category variants + const categoryVariants = await this.categoryRepository.getCategoryVariants(categoryIds); + + const colors = categoryVariants?.colors; + const sizes = categoryVariants?.sizes; + const meterages = categoryVariants?.meterages; + + // fetch products from the category and its children + const { docs, count } = await this.productRepo.getProductsWithCategory(categoryIds, queries, userId); + const products = docs.map((doc: IProduct) => ProductDTO.transformProduct(doc)); + const filters = { attributes: catAttributes?.attributes, priceRange, colors, sizes, meterages }; + + // fetch category breadcrumb + const breadcrumb = await this.getCategoryBreadcrumb(category._id.toString()); + + return { category, brands, products, filters, count, breadcrumb }; + } + + //=========================================>helper method + + // Helper method to get category breadcrumb + private async getCategoryBreadcrumb(categoryId: string): Promise { + const breadcrumb: CategoryTreeDTO[] = []; + let currentCategory = await this.categoryRepository.findById(categoryId); + + while (currentCategory) { + breadcrumb.unshift(CategoryTreeDTO.transformCategory(currentCategory)); + if (currentCategory.parent) { + currentCategory = await this.categoryRepository.findById(currentCategory.parent.toString()); + } else { + currentCategory = null; + } + } + + return breadcrumb; + } + + // Helper function to get all category IDs, including children + private async getAllCategoryIds(rootCategoryId: string): Promise { + const categoriesToProcess = [rootCategoryId]; + const allCategoryIds = new Set([rootCategoryId]); + + while (categoriesToProcess.length > 0) { + const currentCategoryId = categoriesToProcess.pop(); + const currentCategory = await this.categoryRepository.findById(currentCategoryId as string); + + if (currentCategory && currentCategory.children && currentCategory.children.length > 0) { + for (const child of currentCategory.children) { + if (!allCategoryIds.has(child._id.toString())) { + allCategoryIds.add(child._id.toString()); + categoriesToProcess.push(child._id.toString()); + } + } + } + } + + return Array.from(allCategoryIds); + } + // Helper method to check the category ID validity + private async checkCategoryId(catId: string) { + //check if id is valid + if (!isValidObjectId(catId)) throw new BadRequestError(CommonMessage.NotValidId); + //check if category exist in database + const existCategory = await this.categoryRepository.findById(catId); + if (!existCategory) throw new BadRequestError(CategoryMessage.NotValidId); + + return existCategory; + } + + //Helper method to Create variants based on the provided theme + public async createVariantsBasedOnTheme( + session: ClientSession, + theme: CategoryThemeEnum, + colors?: CreateColorDTO[], + sizes?: CreateSizeDTO[], + meterages?: CreateMeterageDTO[], + ): Promise { + const variantRefs: number[] = []; + + if (theme === CategoryThemeEnum.Colored) { + if (colors && colors.length > 0) { + await this.processVariants(this.colorRepo, "name", colors, session, variantRefs); + } else { + throw new BadRequestError(CategoryMessage.MissingColors); + } + } else if (theme === CategoryThemeEnum.Sized) { + if (sizes && sizes.length > 0) { + await this.processVariants(this.sizeRepo, "value", sizes, session, variantRefs); + } else { + throw new BadRequestError(CategoryMessage.MissingSizes); + } + } else if (theme === CategoryThemeEnum.Meterage) { + if (meterages && meterages.length > 0) { + await this.processVariants(this.meterageRepo, "value", meterages, session, variantRefs); + } else { + throw new BadRequestError(CategoryMessage.MissingMeterages); + } + } else if (theme === CategoryThemeEnum.No_color_No_sized) { + return []; + } else { + throw new BadRequestError(CategoryMessage.InvalidTheme); + } + + return variantRefs; + } + + // Generic function to process create variants + private async processVariants( + repo: BaseRepository, + field: keyof T, + items: T[], + session: ClientSession, + variantRefs: number[], + ) { + for (const item of items) { + const query = { [field]: item[field] } as FilterQuery; + console.log("query", query); + const all = await repo.model.find(); + const existingItem = await repo.model.exists({ ...query }); + console.log(existingItem); + console.log("all", all); + if (existingItem) { + variantRefs.push(existingItem._id); + } else { + console.log(item); + const newItem = await repo.model.create([{ ...item }], { session }); + variantRefs.push(newItem[0]._id); + await session.commitTransaction(); + } + } + } +} + +export { CategoryService }; diff --git a/src/modules/category/models/Abstraction/IAttributeValue.ts b/src/modules/category/models/Abstraction/IAttributeValue.ts new file mode 100644 index 0000000..6c98936 --- /dev/null +++ b/src/modules/category/models/Abstraction/IAttributeValue.ts @@ -0,0 +1,5 @@ +export interface IAttributeValue { + _id: number; + attribute: number; + text: string; +} diff --git a/src/modules/category/models/Abstraction/ICategory.ts b/src/modules/category/models/Abstraction/ICategory.ts new file mode 100644 index 0000000..a593f5b --- /dev/null +++ b/src/modules/category/models/Abstraction/ICategory.ts @@ -0,0 +1,21 @@ +import { Types } from "mongoose"; + +import { CategoryThemeEnum } from "../../../../common/enums/category.enum"; + +export interface ICategory { + _id: Types.ObjectId; + title_fa: string; + title_en: string; + icon: string; + imageUrl: string; + description: string; + theme: CategoryThemeEnum; + variants: number[]; + leaf: boolean; + parent: Types.ObjectId; + url: string; + children: ICategory[]; + deleted: boolean; + hierarchy: Types.ObjectId[]; + // attributes: Types.ObjectId; +} diff --git a/src/modules/category/models/Abstraction/ICategoryAttributes.ts b/src/modules/category/models/Abstraction/ICategoryAttributes.ts new file mode 100644 index 0000000..b86ba2e --- /dev/null +++ b/src/modules/category/models/Abstraction/ICategoryAttributes.ts @@ -0,0 +1,16 @@ +import { Types } from "mongoose"; + +import { categoryAttType } from "../../../../common/enums/category.enum"; + +export interface ICategoryAttribute { + _id: number; + category: Types.ObjectId; + title: string; + hint: string; + type: categoryAttType; + multiple: boolean; + required: boolean; + // value: number; + // values: number[]; + // divisions: string[]; +} diff --git a/src/modules/category/models/Abstraction/IColor.ts b/src/modules/category/models/Abstraction/IColor.ts new file mode 100644 index 0000000..8f8fbd9 --- /dev/null +++ b/src/modules/category/models/Abstraction/IColor.ts @@ -0,0 +1,5 @@ +export interface IColor { + _id: number; + name: string; + hexColor: string; +} diff --git a/src/modules/category/models/Abstraction/IMeterage.ts b/src/modules/category/models/Abstraction/IMeterage.ts new file mode 100644 index 0000000..ecacb6f --- /dev/null +++ b/src/modules/category/models/Abstraction/IMeterage.ts @@ -0,0 +1,4 @@ +export interface IMeterage { + _id: number; + value: string; +} diff --git a/src/modules/category/models/Abstraction/ISize.ts b/src/modules/category/models/Abstraction/ISize.ts new file mode 100644 index 0000000..ec3b362 --- /dev/null +++ b/src/modules/category/models/Abstraction/ISize.ts @@ -0,0 +1,4 @@ +export interface ISize { + _id: number; + value: string; +} diff --git a/src/modules/category/models/CategoryAttribute.model.ts b/src/modules/category/models/CategoryAttribute.model.ts new file mode 100644 index 0000000..a0a9588 --- /dev/null +++ b/src/modules/category/models/CategoryAttribute.model.ts @@ -0,0 +1,33 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { ICategoryAttribute } from "./Abstraction/ICategoryAttributes"; +import { AttributeValueModel } from "./attributeValue.model"; +import { categoryAttType } from "../../../common/enums/category.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const CategoryAttributesSchema = new Schema( + { + _id: Number, + category: { type: Schema.Types.ObjectId, ref: "Category", required: true }, + title: { type: String, required: true }, + hint: { type: String, default: null }, + type: { type: String, enum: categoryAttType, required: true }, + multiple: { type: Boolean, default: false }, + required: { type: Boolean, required: true }, + // value: { type: Number, ref: "AttributeValue", default: null }, // Single value + // values: [{ type: Number, ref: "AttributeValue" }], // Multiple values + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); +CategoryAttributesSchema.plugin(AutoIncrement, { id: "catAtt_id", inc_field: "_id" }); + +CategoryAttributesSchema.post(["deleteOne", "findOneAndDelete"], async function (doc, next) { + await AttributeValueModel.deleteMany({ attribute: doc._id }); + next(); +}); + +const CategoryAttributeModel = model("CategoryAttribute", CategoryAttributesSchema); + +export { CategoryAttributeModel }; diff --git a/src/modules/category/models/attributeValue.model.ts b/src/modules/category/models/attributeValue.model.ts new file mode 100644 index 0000000..426c5bb --- /dev/null +++ b/src/modules/category/models/attributeValue.model.ts @@ -0,0 +1,19 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IAttributeValue } from "./Abstraction/IAttributeValue"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const AttributeValueSchema = new Schema( + { + _id: Number, + attribute: { type: Number, ref: "CategoryAttribute", required: true }, + text: { type: String, required: true }, + }, + { timestamps: true }, +); +AttributeValueSchema.plugin(AutoIncrement, { id: "attValue_id", inc_field: "_id" }); +const AttributeValueModel = model("AttributeValue", AttributeValueSchema); + +export { AttributeValueModel }; diff --git a/src/modules/category/models/category.model.ts b/src/modules/category/models/category.model.ts new file mode 100644 index 0000000..9f7387e --- /dev/null +++ b/src/modules/category/models/category.model.ts @@ -0,0 +1,64 @@ +import { CallbackError, Schema, model } from "mongoose"; + +import { ICategory } from "./Abstraction/ICategory"; +import { CategoryThemeEnum } from "../../../common/enums/category.enum"; + +const categorySchema = new Schema( + { + title_fa: { type: String, required: true }, + title_en: { type: String, unique: true, required: true }, + icon: { type: String, default: "/images/icons/default.png" }, + imageUrl: { type: String, required: true }, + description: { type: String, required: true }, + theme: { type: String, enum: CategoryThemeEnum }, + variants: [{ type: Number, refPath: "theme" }], + hierarchy: [{ type: Schema.Types.ObjectId, ref: "Category" }], + leaf: { type: Boolean, default: true }, + parent: { type: Schema.Types.ObjectId, ref: "Category", default: null }, + deleted: { type: Boolean, default: false }, + // variants: { type: [Number], refPath: "theme" }, + // attributes: { type: Schema.Types.ObjectId, ref: "CategoryAttribute" }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +categorySchema.virtual("url").get(function () { + return `/category/${this.title_en}/search`; +}); + +categorySchema.virtual("children", { + ref: "Category", + localField: "_id", + foreignField: "parent", + justOne: false, +}); + +categorySchema.pre("find", function (next) { + this.populate({ path: "children" }).where({ deleted: false }); + next(); +}); +categorySchema.pre("findOne", function (next) { + this.populate({ path: "children" }).where({ deleted: false }); + next(); +}); + +categorySchema.pre("save", async function (next) { + try { + if (this.isNew || this.isModified("parent")) { + if (this.parent) { + const parentCategory = await CategoryModel.findById(this.parent).exec(); + if (!parentCategory) return next(new Error("Parent category not found.")); + this.hierarchy = parentCategory.hierarchy.concat(this._id); + } else { + this.hierarchy = [this._id]; + } + } + + next(); + } catch (err) { + next(err as CallbackError); + } +}); + +const CategoryModel = model("Category", categorySchema); +export { CategoryModel }; diff --git a/src/modules/category/models/color.model.ts b/src/modules/category/models/color.model.ts new file mode 100644 index 0000000..d1a143c --- /dev/null +++ b/src/modules/category/models/color.model.ts @@ -0,0 +1,20 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IColor } from "./Abstraction/IColor"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const ColorSchema = new Schema( + { + _id: Number, + name: { type: String, unique: true, required: true }, + hexColor: { type: String, unique: true, required: true }, + }, + { timestamps: false, _id: false, toJSON: { versionKey: false } }, +); + +ColorSchema.plugin(AutoIncrement, { id: "color_id", inc_field: "_id" }); +const ColorModel = model("Color", ColorSchema); + +export { ColorModel }; diff --git a/src/modules/category/models/meterage.model.ts b/src/modules/category/models/meterage.model.ts new file mode 100644 index 0000000..42c0700 --- /dev/null +++ b/src/modules/category/models/meterage.model.ts @@ -0,0 +1,19 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IMeterage } from "./Abstraction/IMeterage"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const MeterageSchema = new Schema( + { + _id: Number, + value: { type: String, unique: true, required: true }, + }, + { timestamps: false, _id: false, toJSON: { versionKey: false } }, +); + +MeterageSchema.plugin(AutoIncrement, { id: "meterage_id", inc_field: "_id" }); +const MeterageModel = model("Meterage", MeterageSchema); + +export { MeterageModel }; diff --git a/src/modules/category/models/size.model.ts b/src/modules/category/models/size.model.ts new file mode 100644 index 0000000..c5a069b --- /dev/null +++ b/src/modules/category/models/size.model.ts @@ -0,0 +1,19 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { ISize } from "./Abstraction/ISize"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const SizeSchema = new Schema( + { + _id: Number, + value: { type: String, unique: true, required: true }, + }, + { timestamps: false, _id: false, toJSON: { versionKey: false } }, +); + +SizeSchema.plugin(AutoIncrement, { id: "size_id", inc_field: "_id" }); +const SizeModel = model("Size", SizeSchema); + +export { SizeModel }; diff --git a/src/modules/chat/DTO/createChat.dto.ts b/src/modules/chat/DTO/createChat.dto.ts new file mode 100644 index 0000000..2eee871 --- /dev/null +++ b/src/modules/chat/DTO/createChat.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { AdminModel } from "../../admin/models/admin.model"; +import { SellerModel } from "../../seller/models/seller.model"; + +export class CreateChatDTO { + @Expose() + @IsNotEmpty() + @IsValidId([AdminModel, SellerModel]) + @ApiProperty({ type: "string", description: "the seller id that user want to start chat with", example: "66eff8c0c6ad5530b996c432" }) + sellerId: string; +} diff --git a/src/modules/chat/DTO/createMessage.dto.ts b/src/modules/chat/DTO/createMessage.dto.ts new file mode 100644 index 0000000..3c17a8e --- /dev/null +++ b/src/modules/chat/DTO/createMessage.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateMessageDTO { + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "The id of chat" }) + chatId: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "The content of the message" }) + content: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "The ID of the receiver" }) + receiver: string; +} diff --git a/src/modules/chat/DTO/searchChatQuery.dto.ts b/src/modules/chat/DTO/searchChatQuery.dto.ts new file mode 100644 index 0000000..0d87e12 --- /dev/null +++ b/src/modules/chat/DTO/searchChatQuery.dto.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer"; +import { IsOptional, IsString } from "class-validator"; + +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { PaginationDTO } from "../../../common/dto/pagination.dto"; + +export class SearchChatQueryDTO extends PaginationDTO { + @Expose() + @IsOptional() + @IsString() + q: string; + + @Expose() + @IsOptional() + @IsValidPersianDate() + since: string; +} diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts new file mode 100644 index 0000000..ee7fbeb --- /dev/null +++ b/src/modules/chat/chat.controller.ts @@ -0,0 +1,126 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody, requestParam } from "inversify-express-utils"; + +import { ChatService } from "./chat.service"; +import { CreateChatDTO } from "./DTO/createChat.dto"; +import { HttpStatus } from "../../common"; +import { SearchChatQueryDTO } from "./DTO/searchChatQuery.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { ParamDto } from "../../common/dto/param.dto"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IAdmin } from "../admin/models/Abstraction/IAdmin"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/chat") +@ApiTags("Chat") +class ChatController extends BaseController { + @inject(IOCTYPES.ChatService) private chatService: ChatService; + + @ApiOperation("create a chat with a seller with seller id ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateChatDTO) + @ApiAuth() + @httpPost("/create", Guard.authUser(), ValidationMiddleware.validateInput(CreateChatDTO)) + public async createChat(@request() req: Request, @requestBody() createDto: CreateChatDTO) { + const user = req.user as IUser; + const data = await this.chatService.createChat(user._id.toString(), createDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all chat of user ==> login as user") + @ApiResponse("successful") + @ApiAuth() + @httpGet("/user", Guard.authUser()) + public async getUserChats(@request() req: Request) { + const user = req.user as IUser; + const data = await this.chatService.getUserChats(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get all chat of seller ==> login as seller") + @ApiResponse("successful") + @ApiAuth() + @httpGet("/seller", Guard.authSeller()) + public async getSellerChats(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.chatService.getSellerChats(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get all chat of admin ==> login as admin") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("q", "search query") + @ApiQuery("since", "search since date ==> 1403/01/15") + @ApiResponse("successful") + @ApiAuth() + @httpGet("/admin", Guard.authAdmin(), ValidationMiddleware.validateQuery(SearchChatQueryDTO)) + public async getAdminChats(@request() req: Request, @queryParam() queryDto: SearchChatQueryDTO) { + const admin = req.user as IAdmin; + const { chats, count } = await this.chatService.getAdminChats(admin._id.toString(), queryDto); + const { pager } = this.paginate(count); + return this.response({ pager, chats }); + } + + @ApiOperation("get all chat of user ==> login as user") + @ApiResponse("successful") + @ApiParam("chatId", "id of the chat", true) + @ApiAuth() + @httpGet("/user/:chatId", Guard.authUser()) + public async getUserSingleChat(@request() req: Request, @requestParam("chatId") chatId: string) { + const user = req.user as IUser; + const data = await this.chatService.getUserSingleChat(user._id.toString(), chatId); + return this.response(data); + } + + @ApiOperation("get all chat of seller ==> login as seller") + @ApiResponse("successful") + @ApiParam("chatId", "id of the chat", true) + @ApiAuth() + @httpGet("/seller/:chatId", Guard.authSeller()) + public async getSellerSingleChat(@request() req: Request, @requestParam("chatId") chatId: string) { + const seller = req.user as ISeller; + const data = await this.chatService.getSellerSingleChat(seller._id.toString(), chatId); + return this.response(data); + } + + @ApiOperation("get all chat of admin ==> login as admin") + @ApiResponse("successful") + @ApiParam("chatId", "id of the chat", true) + @ApiAuth() + @httpGet("/admin/:chatId", Guard.authAdmin()) + public async getAdminSingleChat(@request() req: Request, @requestParam("chatId") chatId: string) { + const admin = req.user as IAdmin; + const data = await this.chatService.getSellerSingleChat(admin._id.toString(), chatId); + return this.response(data); + } + + @ApiOperation("get all seller and user chat for admin ==> login as admin") + @ApiAuth() + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("q", "search query") + @ApiQuery("since", "search since date ==> 1403/01/15") + @httpGet("", Guard.authAdmin(), ValidationMiddleware.validateQuery(SearchChatQueryDTO)) + public async getAllChatForAdmin(@queryParam() queryDto: SearchChatQueryDTO) { + const { count, chats } = await this.chatService.getAllChatForAdmin(queryDto); + const { pager } = this.paginate(count); + return this.response({ pager, chats }); + } + + @ApiOperation("get single chat details for admin ==> login as admin") + @ApiAuth() + @ApiParam("id", "id of the chat", true) + @httpGet("/:id", Guard.authAdmin(), ValidationMiddleware.validateParameter(ParamDto)) + public async getSingleChatDetail(@requestParam() paramDto: ParamDto) { + const data = await this.chatService.getSingleChatDetailForAdmin(paramDto); + return this.response(data); + } +} + +export { ChatController }; diff --git a/src/modules/chat/chat.gateway.ts b/src/modules/chat/chat.gateway.ts new file mode 100644 index 0000000..6a3a3eb --- /dev/null +++ b/src/modules/chat/chat.gateway.ts @@ -0,0 +1,125 @@ +import { Server as HttpServer } from "http"; + +import { inject, injectable } from "inversify"; +import { Server, Socket } from "socket.io"; + +import { ChatService } from "./chat.service"; +import { CreateMessageDTO } from "./DTO/createMessage.dto"; +import { MessageParticipantRef } from "./models/Abstraction/IChatMessage"; +import { WsAuthService } from "./wsAuth.service"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class ChatGateway { + private io: Server; + @inject(IOCTYPES.ChatService) private chatService: ChatService; + @inject(IOCTYPES.WsAuthService) private wsAuthService: WsAuthService; + + public initialize(server: HttpServer) { + this.io = new Server(server, { + path: "/ws-chat", + // addTrailingSlash: false, + cors: { + origin: true, + allowedHeaders: ["Authorization"], + credentials: true, + methods: ["GET", "POST"], + }, + }); + this.io.on("connection", (socket: Socket) => this.handleConnection(socket)); + return this.io; + } + //################################################### + //################################################### + private async handleConnection(socket: Socket) { + try { + const token = socket.handshake.query.token as string; + const type = socket.handshake.query.type as MessageParticipantRef; + const data = await this.wsAuthService.verifyToken(token, type); + + if (!data) { + socket.emit("unauthorized", { message: "unauthorized" }); + throw new Error("unauthorized ==> ws"); + } + const user = { id: data._id.toString(), name: data.fullName }; + + socket.data.user = user; + console.log(`User connected: ${user.id}`); + + // socket.join(`user_${user.id}`); + this.io.emit("userOnline", user.id); + this.chatService.userConnected(user.id, socket.id); + + // Send unread messages + await this.sendUnreadMessages(user.id, socket); + + socket.on("joinChat", (chatId: string) => this.handleJoinRoom(chatId, socket)); + socket.on("leaveChat", (chatId: string) => this.handleLeaveRoom(chatId, socket)); + socket.on("newMessage", (data) => this.createMessage(data, socket, type)); + socket.on("disconnect", () => this.handleDisconnect(socket)); + } catch (error) { + console.error("Connection error", error); + socket.disconnect(); + } + } + //################################################### + //################################################### + + private async createMessage(data: CreateMessageDTO, socket: Socket, type: MessageParticipantRef) { + try { + console.log("data ==> new Message :", data); + const userId = socket.data.user.id; + await this.chatService.checkChatId(data.chatId); + await this.chatService.createMessage(userId, data, type); + + // const receiverSocketId = this.chatService.getSocketId(data.receiver); + // if (receiverSocketId) { + // console.log("here"); + + // this.io.to(receiverSocketId).emit("newMessage", message); + // } + // console.log("here"); + + this.io.to(data.chatId).emit("newMessage", { sender: socket.data.user.name, content: data.content }); + } catch (error) { + console.error("Message creation error", error); + socket.emit("error", { message: "Message creation failed" }); + } + } + //################################################### + //################################################### + private async sendUnreadMessages(userId: string, socket: Socket) { + const unreadMessages = await this.chatService.getUnreadMessages(userId); + socket.emit("unreadMessages", unreadMessages); + } + //################################################### + //################################################### + private async handleJoinRoom(chatId: string, socket: Socket) { + try { + const user = socket.data.user; + await this.chatService.checkChatId(chatId); + console.log(`User ${user.id} joined chat ${chatId}`); + socket.join(chatId); + } catch (error) { + console.error("error in joining chat room", error); + socket.emit("error", { message: "joining chat room failed" }); + } + } + //################################################### + //################################################### + private handleLeaveRoom(chatId: string, socket: Socket) { + const user = socket.data.user; + console.log(`User ${user.id} left chat ${chatId}`); + socket.leave(chatId); // Leave the room + } + //################################################### + //################################################### + private handleDisconnect(socket: Socket) { + const user = socket.data.user; + if (user) { + console.log(`User disconnected: ${user.id}`); + this.chatService.userDisconnected(user.id); + this.io.emit("userOffline", user.id); + } + } +} diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts new file mode 100644 index 0000000..4c734d2 --- /dev/null +++ b/src/modules/chat/chat.service.ts @@ -0,0 +1,273 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId, startSession } from "mongoose"; + +import { CreateChatDTO } from "./DTO/createChat.dto"; +import { CreateMessageDTO } from "./DTO/createMessage.dto"; +import { MessageParticipantRef } from "./models/Abstraction/IChatMessage"; +import { ChatRepo } from "./repository/chat.repository"; +import { ChatMessageRepo } from "./repository/message.repository"; +import { ParamDto } from "../../common/dto/param.dto"; +import { ChatMessage, CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { AdminRepository } from "../admin/repository/admin"; +import { NotificationService } from "../notification/notification.service"; +import { SellerRepository } from "../seller/seller.repository"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { UserRepository } from "../user/user.repository"; +import { SearchChatQueryDTO } from "./DTO/searchChatQuery.dto"; + +@injectable() +class ChatService { + //################################################# + + @inject(IOCTYPES.ChatRepository) private chatRepo: ChatRepo; + @inject(IOCTYPES.AdminRepository) private adminRepo: AdminRepository; + @inject(IOCTYPES.ChatMessageRepository) private chatMessageRepo: ChatMessageRepo; + @inject(IOCTYPES.UserRepository) userRepo: UserRepository; + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + + private onlineUsers: Map = new Map(); + + //################################################# + //################################################# + + async getAllChatForAdmin(queryDto: SearchChatQueryDTO) { + const { chats, count } = await this.chatRepo.getAllChats(queryDto); + return { + chats, + count, + }; + } + async getUnreadChatCount(sellerId: string) { + return this.chatMessageRepo.model.countDocuments({ receiver: sellerId, isRead: false }); + } + + async getSingleChatDetailForAdmin(paramDto: ParamDto) { + const chat = await this.chatRepo.getSingleChat(paramDto.id); + if (!chat) throw new BadRequestError(ChatMessage.ChatNotFound); + const messages = await this.chatMessageRepo.model.find({ chatId: chat._id }); + return { + chat, + messages, + }; + } + + async createChat(userId: string, createDto: CreateChatDTO) { + const session = await startSession(); + session.startTransaction(); + try { + let sellerRef; + const isAdmin = await this.adminRepo.model.exists({ _id: createDto.sellerId }).session(session); + if (isAdmin) sellerRef = OwnerRef.ADMIN; + else sellerRef = OwnerRef.SELLER; + + const chatRecipient = isAdmin ? "admin" : "seller"; + await this.notificationService.notifyNewChat(createDto.sellerId, chatRecipient, session); + + const existChat = await this.chatRepo.model.findOne({ user: userId, seller: createDto.sellerId, sellerRef }).lean(); + if (existChat) return { existChat }; + const newChat = await this.chatRepo.model.create( + [ + { + user: userId, + seller: createDto.sellerId, + sellerRef, + }, + ], + { session }, + ); + + await session.commitTransaction(); + return { + message: CommonMessage.Created, + newChat: newChat[0], + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + //################################################# + //################################################# + async getUserChats(userId: string) { + const chats = await this.chatRepo.getUserChats(userId); + return { chats }; + } + //################################################# + //################################################# + async getSellerChats(sellerId: string) { + const chats = await this.chatRepo.getSellerChats(sellerId); + return { chats }; + } + + async getAdminChats(adminId: string, queryDto: SearchChatQueryDTO) { + const { count, chats } = await this.chatRepo.getAdminChats(adminId, queryDto); + return { chats, count }; + } + //################################################# + //################################################# + async getSellerSingleChat(sellerId: string, chatId: string) { + const chat = await this.chatRepo.getSingleChat(chatId, sellerId); + if (!chat) throw new BadRequestError(ChatMessage.ChatNotFound); + await this.chatMessageRepo.model.updateMany( + { chatId: chat._id, receiverRef: MessageParticipantRef.Admin }, + { isRead: true }, + { new: true }, + ); + const messages = await this.chatMessageRepo.model.find({ chatId: chat._id }); + return { + chat, + messages, + }; + } + //################################################# + //################################################# + async getUserSingleChat(userId: string, chatId: string) { + const chat = await this.chatRepo.model.findOne({ user: userId, _id: chatId }); + if (!chat) throw new BadRequestError(ChatMessage.ChatNotFound); + const messages = await this.chatMessageRepo.model.find({ chatId: chat._id }); + return { + chat, + messages, + }; + } + //################################################# + //################################################# + async createMessage(senderId: string, data: CreateMessageDTO, type: MessageParticipantRef) { + await this.checkChatId(data.chatId); + const receiver = await this.validateChatReceiver(data.receiver, type); + const receiverRef: MessageParticipantRef = receiver; + + const newMessage = await this.chatMessageRepo.model.create({ + chatId: data.chatId, + content: data.content, + sender: senderId, + senderRef: type, + receiver: data.receiver, + receiverRef, + }); + + return { sender: newMessage.sender, content: newMessage.content }; + } + + //################################################# + //################################################# + userConnected(userId: string, socketId: string) { + this.onlineUsers.set(userId, socketId); + console.log(this.onlineUsers); + } + //################################################# + //################################################# + userDisconnected(userId: string) { + this.onlineUsers.delete(userId); + } + //################################################# + //################################################# + getSocketId(userId: string): string | undefined { + return this.onlineUsers.get(userId); + } + //################################################# + //################################################# + async getUnreadMessages(userId: string) { + const unreadMessages = await this.chatMessageRepo.model.find({ isRead: false, receiver: userId }); + return unreadMessages; + } + + //################################################# + async checkChatId(chatId: string) { + if (!isValidObjectId(chatId)) throw new BadRequestError("chat with this id not exist"); + const existChat = await this.chatRepo.model.exists({ _id: chatId }); + if (!existChat) throw new BadRequestError("chat with this id not exist"); + return true; + } + + //################################################# + private async validateChatReceiver(receiver: string, type: MessageParticipantRef) { + if (!isValidObjectId(receiver)) throw new BadRequestError("receiver id is not valid"); + + if (type === MessageParticipantRef.USER) { + const existSeller = await this.sellerRepo.model.exists({ _id: receiver }); + if (existSeller) return MessageParticipantRef.SELLER; + + const existAdmin = await this.adminRepo.model.exists({ _id: receiver }); + if (existAdmin) return MessageParticipantRef.Admin; + + throw new BadRequestError("receiver id (seller id or admin id) not found or its incorrect "); + // + } else if (type === MessageParticipantRef.Admin) { + const existUser = await this.userRepo.model.exists({ _id: receiver }); + if (!existUser) throw new BadRequestError("receiver id(user id) not found or its incorrect"); + return MessageParticipantRef.USER; + // + } else if (type === MessageParticipantRef.SELLER) { + const existUser = await this.userRepo.model.exists({ _id: receiver }); + if (!existUser) throw new BadRequestError("receiver id(user id) not found or its incorrect"); + return MessageParticipantRef.USER; + // + } + throw new BadRequestError("receiver not found or incorrect"); + } +} + +export { ChatService }; + +// class ChatService { +// private userSocketMap: Map> = new Map(); // user.id to socket.id set + +// public userConnected(userId: number, socketId: string) { +// if (!this.userSocketMap.has(userId)) { +// this.userSocketMap.set(userId, new Set()); +// } +// this.userSocketMap.get(userId).add(socketId); +// } + +// public userDisconnected(userId: number, socketId: string) { +// if (this.userSocketMap.has(userId)) { +// const userSockets = this.userSocketMap.get(userId); +// userSockets.delete(socketId); + +// // Clean up if no sockets remain for the user +// if (userSockets.size === 0) { +// this.userSocketMap.delete(userId); +// } +// } +// } + +// public getSocketIds(userId: number): string[] { +// return Array.from(this.userSocketMap.get(userId) || []); +// } +// } + +// private async createMessage(data: any, socket: Socket) { +// try { +// const userId = socket.data.user.id; +// const message = await this.chatService.createMessage(userId, data); + +// // Get all receiver socket IDs and send the message to each one +// const receiverSocketIds = this.chatService.getSocketIds(data.receiverId); +// receiverSocketIds.forEach((receiverSocketId) => { +// this.io.to(receiverSocketId).emit("newMessage", message); +// }); +// } catch (error) { +// console.error("Message creation error", error); +// socket.emit("error", { message: "Message creation failed" }); +// } +// } + +// private handleDisconnect(socket: Socket) { +// const user = socket.data.user; +// if (user) { +// console.log(`User disconnected: ${user.id}`); +// this.chatService.userDisconnected(user.id, socket.id); + +// // If no more active connections for this user, broadcast that they are offline +// const remainingSockets = this.chatService.getSocketIds(user.id); +// if (remainingSockets.length === 0) { +// this.io.emit("userOffline", user.id); +// } +// } +// } diff --git a/src/modules/chat/models/Abstraction/IChat.ts b/src/modules/chat/models/Abstraction/IChat.ts new file mode 100644 index 0000000..6bfa0ae --- /dev/null +++ b/src/modules/chat/models/Abstraction/IChat.ts @@ -0,0 +1,11 @@ +import { Types } from "mongoose"; + +import { OwnerRef } from "../../../shop/models/Abstraction/IShop"; + +export interface IChat { + _id: Types.ObjectId; + shortId: number; + seller: Types.ObjectId; + sellerRef: OwnerRef; + user: Types.ObjectId; +} diff --git a/src/modules/chat/models/Abstraction/IChatMessage.ts b/src/modules/chat/models/Abstraction/IChatMessage.ts new file mode 100644 index 0000000..f429241 --- /dev/null +++ b/src/modules/chat/models/Abstraction/IChatMessage.ts @@ -0,0 +1,26 @@ +import { Types } from "mongoose"; + +export enum MessageParticipantRef { + SELLER = "Seller", + Admin = "Admin", + USER = "User", +} +export enum MessageType { + TEXT = "text", + IMAGE = "image", + VIDEO = "video", + VOICE = "voice", +} + +export interface IChatMessage { + _id: Types.ObjectId; + sender: Types.ObjectId; + senderRef: MessageParticipantRef; + receiver: Types.ObjectId; + receiverRef: MessageParticipantRef; + chatId: Types.ObjectId; + content: string; + isRead: boolean; + // type: MessageType; + // isEdited: boolean; +} diff --git a/src/modules/chat/models/chat.model.ts b/src/modules/chat/models/chat.model.ts new file mode 100644 index 0000000..5836829 --- /dev/null +++ b/src/modules/chat/models/chat.model.ts @@ -0,0 +1,42 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IChat } from "./Abstraction/IChat"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const ChatSchema = new Schema( + { + shortId: Number, + seller: { type: Schema.Types.ObjectId, required: true, refPath: "sellerRef" }, + sellerRef: { type: String, enum: OwnerRef, required: true }, + user: { type: Schema.Types.ObjectId, required: true, ref: "User" }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +ChatSchema.virtual("lastMessage", { + ref: "ChatMessage", + localField: "_id", + foreignField: "chatId", + justOne: true, + options: { sort: { createdAt: -1 }, limit: 1 }, +}); + +ChatSchema.virtual("shopLogo", { + ref: "Shop", + localField: "seller", + foreignField: "owner", + justOne: true, + options: { select: "logo" }, +}); + +ChatSchema.pre(["find", "findOne"], function (next) { + this.populate([{ path: "lastMessage" }, { path: "shopLogo" }]); + next(); +}); + +ChatSchema.plugin(AutoIncrement, { id: "chat_id", inc_field: "shortId" }); +const ChatModel = model("Chat", ChatSchema); +export { ChatModel }; diff --git a/src/modules/chat/models/chatMessage.model.ts b/src/modules/chat/models/chatMessage.model.ts new file mode 100644 index 0000000..c054672 --- /dev/null +++ b/src/modules/chat/models/chatMessage.model.ts @@ -0,0 +1,20 @@ +import { Schema, model } from "mongoose"; + +import { IChatMessage, MessageParticipantRef } from "./Abstraction/IChatMessage"; + +const ChatMessageSchema = new Schema( + { + sender: { type: Schema.Types.ObjectId, refPath: "senderRef", required: true }, + senderRef: { type: String, enum: MessageParticipantRef, required: true }, + receiver: { type: Schema.Types.ObjectId, refPath: "receiverRef", required: true }, + receiverRef: { type: String, enum: MessageParticipantRef, required: true }, + chatId: { type: Schema.Types.ObjectId, ref: "Chat", required: true }, + content: { type: String, required: true }, + isRead: { type: Boolean, default: false }, + // type: { type: String, enum: MessageType, default: MessageType.TEXT }, + // isEdited: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +export const ChatMessageModel = model("ChatMessage", ChatMessageSchema); diff --git a/src/modules/chat/repository/chat.repository.ts b/src/modules/chat/repository/chat.repository.ts new file mode 100644 index 0000000..63c12cf --- /dev/null +++ b/src/modules/chat/repository/chat.repository.ts @@ -0,0 +1,196 @@ +import { FilterQuery, Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { paginationUtils } from "../../../utils/pagination.utils"; +import { TimeService } from "../../../utils/time.service"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { SearchChatQueryDTO } from "../DTO/searchChatQuery.dto"; +import { IChat } from "../models/Abstraction/IChat"; +import { ChatModel } from "../models/chat.model"; + +class ChatRepo extends BaseRepository { + private userSelect = { + _id: 1, + fullName: 1, + }; + + constructor() { + super(ChatModel); + } + + async getAllChats(queryDto: SearchChatQueryDTO) { + const { limit, skip } = paginationUtils(queryDto); + + const searchQuery: FilterQuery = {}; + + if (queryDto.since) { + searchQuery.createdAt = { + $gte: new Date(TimeService.convertPersianToGregorian(queryDto.since)), + }; + } + + const docs = await this.model.aggregate([ + { + $match: searchQuery, + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $lookup: { + from: "sellers", + localField: "seller", + foreignField: "_id", + as: "seller", + }, + }, + { + $lookup: { + from: "shops", + localField: "seller._id", + foreignField: "owner", + as: "shop", + }, + }, + { $unwind: "$user" }, + { $unwind: "$seller" }, + { $unwind: "$shop" }, + { + $addFields: { + "seller.shop": "$shop", + }, + }, + { + $project: { + shop: 0, + }, + }, + { + $match: { + $or: [ + { "user.fullName": { $regex: queryDto.q ?? "", $options: "i" } }, + { "seller.fullName": { $regex: queryDto.q ?? "", $options: "i" } }, + ], + }, + }, + { + $facet: { + chats: [{ $skip: skip }, { $limit: limit }], + count: [{ $count: "count" }], + }, + }, + ]); + return { chats: docs?.[0].chats || [], count: docs?.[0].count[0]?.count || 0 }; + } + + async getSingleChat(chatId: string, sellerId?: string) { + const query: FilterQuery = { _id: chatId }; + if (sellerId) query.seller = sellerId; + const chat = await this.model.findOne(query).populate([ + { path: "user", select: this.userSelect }, + { path: "seller", select: this.userSelect }, + ]); + + return chat; + } + + async getSellerChats(sellerId: string) { + return this.model.find({ seller: sellerId }).populate([{ path: "user", select: this.userSelect }]); + } + + async getAdminChats(adminId: string, queryDto: SearchChatQueryDTO) { + const { skip, limit } = paginationUtils(queryDto); + const searchQuery: FilterQuery = { + seller: new Types.ObjectId(adminId), + sellerRef: OwnerRef.ADMIN, + }; + + if (queryDto.since) { + searchQuery.createdAt = { + $gte: new Date(TimeService.convertPersianToGregorian(queryDto.since)), + }; + } + + const docs = await this.model.aggregate([ + { + $match: searchQuery, + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $lookup: { + from: "admins", + localField: "seller", + foreignField: "_id", + as: "seller", + }, + }, + { + $lookup: { + from: "chatmessages", + localField: "_id", + foreignField: "chatId", + as: "messages", + }, + }, + { $unwind: "$user" }, + { $unwind: "$seller" }, + { + $addFields: { + isRead: { + $allElementsTrue: { + $map: { + input: "$messages", + as: "message", + in: { + $cond: { + if: { $eq: ["$$message.senderRef", "User"] }, + then: "$$message.isRead", + else: true, + }, + }, + }, + }, + }, + }, + }, + { + $match: { + $or: [ + { "user.fullName": { $regex: queryDto.q ?? "", $options: "i" } }, + { "seller.fullName": { $regex: queryDto.q ?? "", $options: "i" } }, + ], + }, + }, + { + $facet: { + chats: [{ $skip: skip }, { $limit: limit }], + count: [{ $count: "count" }], + }, + }, + ]); + + return { chats: docs?.[0].chats || [], count: docs?.[0].count[0]?.count || 0 }; + } + + async getUserChats(userId: string) { + return this.model.find({ user: userId }).populate([{ path: "seller", select: this.userSelect }]); + } +} + +function createChatRepo(): ChatRepo { + return new ChatRepo(); +} + +export { ChatRepo, createChatRepo }; diff --git a/src/modules/chat/repository/message.repository.ts b/src/modules/chat/repository/message.repository.ts new file mode 100644 index 0000000..b0d0d39 --- /dev/null +++ b/src/modules/chat/repository/message.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IChatMessage } from "../models/Abstraction/IChatMessage"; +import { ChatMessageModel } from "../models/chatMessage.model"; + +class ChatMessageRepo extends BaseRepository { + constructor() { + super(ChatMessageModel); + } +} + +function createChatMessageRepo(): ChatMessageRepo { + return new ChatMessageRepo(); +} + +export { ChatMessageRepo, createChatMessageRepo }; diff --git a/src/modules/chat/wsAuth.service.ts b/src/modules/chat/wsAuth.service.ts new file mode 100644 index 0000000..3dd7a20 --- /dev/null +++ b/src/modules/chat/wsAuth.service.ts @@ -0,0 +1,50 @@ +import { inject, injectable } from "inversify"; + +import { IOCTYPES } from "../../IOC/ioc.types"; +import { SellerRepository } from "../seller/seller.repository"; +import { TokenService } from "../token/token.service"; +import { UserRepository } from "../user/user.repository"; +import { MessageParticipantRef } from "./models/Abstraction/IChatMessage"; +import { AdminRepository } from "../admin/repository/admin"; + +@injectable() +class WsAuthService { + @inject(IOCTYPES.UserRepository) userRepo: UserRepository; + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.AdminRepository) adminRepo: AdminRepository; + @inject(IOCTYPES.TokenService) private tokenService: TokenService; + async verifyToken(token: string, type: MessageParticipantRef) { + try { + const decoded = this.tokenService.verifyToken(token); + const { sub } = decoded; + + if (type === MessageParticipantRef.USER) { + const user = await this.getUserById(sub); + return user; + } else if (type === MessageParticipantRef.SELLER) { + const seller = await this.getSellerById(sub); + return seller; + } else if (type === MessageParticipantRef.Admin) { + const admin = await this.getAdminById(sub); + return admin; + } + //if type is incorrect + return null; + } catch (error) { + console.error("Token verification failed", error); + return null; + } + } + private async getAdminById(adminId: string) { + return this.adminRepo.findById(adminId); + } + + private async getUserById(userId: string) { + return this.userRepo.findById(userId); + } + private async getSellerById(sellerId: string) { + return this.sellerRepo.findById(sellerId); + } +} + +export { WsAuthService }; diff --git a/src/modules/contact-us/DTO/createContactRequest.dto.ts b/src/modules/contact-us/DTO/createContactRequest.dto.ts new file mode 100644 index 0000000..0db0e0b --- /dev/null +++ b/src/modules/contact-us/DTO/createContactRequest.dto.ts @@ -0,0 +1,34 @@ +import { Expose, plainToClass } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IContactUs } from "../model/Abstractions/IContactUs"; + +export class CreateContactRequestDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "fullName of customer", example: "hamid zarghami" }) + fullName: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "email or phone of customer", example: "hamid@gmail.com || 09126548969" }) + email_phone: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "message content of customer", example: "hamid zarghami hastam che khabar sondos" }) + message: string; + + public static transformContactUs(data: IContactUs): CreateContactRequestDTO { + const OrderDTO = plainToClass(CreateContactRequestDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return OrderDTO; + } +} diff --git a/src/modules/contact-us/contactUs.controller.ts b/src/modules/contact-us/contactUs.controller.ts new file mode 100644 index 0000000..09dd1a2 --- /dev/null +++ b/src/modules/contact-us/contactUs.controller.ts @@ -0,0 +1,27 @@ +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpPost, requestBody } from "inversify-express-utils"; + +import { ContactUsService } from "./contactUs.service"; +import { CreateContactRequestDTO } from "./DTO/createContactRequest.dto"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/contact-us") +@ApiTags("ContactUs") +export class ContactUsController extends BaseController { + @inject(IOCTYPES.ContactUsService) contactUsService: ContactUsService; + + @ApiOperation("create a contactUs request") + @ApiResponse("created successfully", HttpStatus.Ok) + @ApiModel(CreateContactRequestDTO) + @httpPost("", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(CreateContactRequestDTO)) + public async createContactUsRequest(@requestBody() createDto: CreateContactRequestDTO) { + const data = await this.contactUsService.createContactRequest(createDto); + return this.response(data, HttpStatus.Ok); + } +} diff --git a/src/modules/contact-us/contactUs.repository.ts b/src/modules/contact-us/contactUs.repository.ts new file mode 100644 index 0000000..4504934 --- /dev/null +++ b/src/modules/contact-us/contactUs.repository.ts @@ -0,0 +1,50 @@ +import { IContactUs } from "./model/Abstractions/IContactUs"; +import { ContactUsModel } from "./model/contactUs.model"; +import { BaseRepository } from "../../common/base/repository"; +import { AdminContactUsQueries } from "../../common/types/query.type"; + +export class ContactUsRepo extends BaseRepository { + constructor() { + super(ContactUsModel); + } + + async getContactUsForAdminPanel(queries: AdminContactUsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const docs = await this.model.aggregate([ + { + $match: { + ...(queries.email_phone && { + email_phone: queries.email_phone, + }), + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } +} + +export function CreateContactUsRepo(): ContactUsRepo { + return new ContactUsRepo(); +} diff --git a/src/modules/contact-us/contactUs.service.ts b/src/modules/contact-us/contactUs.service.ts new file mode 100644 index 0000000..34bb7ae --- /dev/null +++ b/src/modules/contact-us/contactUs.service.ts @@ -0,0 +1,50 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { ContactUsRepo } from "./contactUs.repository"; +import { CreateContactRequestDTO } from "./DTO/createContactRequest.dto"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { AdminContactUsQueries } from "../../common/types/query.type"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class ContactUsService { + @inject(IOCTYPES.ContactUsRepo) contactUsRepo: ContactUsRepo; + + async createContactRequest(createDto: CreateContactRequestDTO) { + await this.contactUsRepo.model.findOneAndUpdate( + { email_phone: createDto.email_phone }, + { + ...createDto, + }, + { upsert: true }, + ); + + return { + message: CommonMessage.Created, + }; + } + + async getAllContactUs(queries: AdminContactUsQueries) { + const { docs, count } = await this.contactUsRepo.getContactUsForAdminPanel(queries); + const contactUs = docs.map((doc: any) => CreateContactRequestDTO.transformContactUs(doc)); + + return { contactUs, count }; + } + + async getAll() { + return { + contactRequest: await this.contactUsRepo.findAll(), + }; + } + + async getById(requestId: string) { + if (!isValidObjectId(requestId)) throw new BadRequestError(CommonMessage.NotValidId); + const request = await this.contactUsRepo.model.findById(requestId); + if (!request) throw new BadRequestError(CommonMessage.NotFoundById); + return { + contactRequest: request, + }; + } +} diff --git a/src/modules/contact-us/model/Abstractions/IContactUs.ts b/src/modules/contact-us/model/Abstractions/IContactUs.ts new file mode 100644 index 0000000..9fbfe37 --- /dev/null +++ b/src/modules/contact-us/model/Abstractions/IContactUs.ts @@ -0,0 +1,5 @@ +export interface IContactUs { + fullName: string; + email_phone: string; + message: string; +} diff --git a/src/modules/contact-us/model/contactUs.model.ts b/src/modules/contact-us/model/contactUs.model.ts new file mode 100644 index 0000000..c2357df --- /dev/null +++ b/src/modules/contact-us/model/contactUs.model.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +import { IContactUs } from "./Abstractions/IContactUs"; + +const ContactUsSchema = new Schema( + { + fullName: { type: String, required: true }, + email_phone: { type: String, required: true }, + message: { type: String, required: true }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); +const ContactUsModel = model("ContactUs", ContactUsSchema); + +export { ContactUsModel }; diff --git a/src/modules/faq/DTO/createFaq.dto.ts b/src/modules/faq/DTO/createFaq.dto.ts new file mode 100644 index 0000000..fc0c741 --- /dev/null +++ b/src/modules/faq/DTO/createFaq.dto.ts @@ -0,0 +1,29 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { PageEnum } from "../../../common/enums/page.enum"; + +export class CreateFaqDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "faq question", example: "نحوه ثبت نام چگونه است؟" }) + question: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "faq answer", example: "به آن صورت می باشد" }) + answer: string; + + @Expose() + @IsNotEmpty() + @IsEnum(PageEnum) + @ApiProperty({ + type: "string", + description: "Type of the Page", + example: "Faq | Seller | ProductTag | ProductDescription | Benefit | ShipmentProcess | Policy", + }) + page: PageEnum; +} diff --git a/src/modules/faq/DTO/updateFaq.dto.ts b/src/modules/faq/DTO/updateFaq.dto.ts new file mode 100644 index 0000000..f9d8dfd --- /dev/null +++ b/src/modules/faq/DTO/updateFaq.dto.ts @@ -0,0 +1,30 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { FaqModel } from "../models/faq.model"; + +export class UpdateFaqDTO { + @Expose() + @IsNotEmpty() + @IsString() + @Length(24, 24) + @IsValidId(FaqModel) + @ApiProperty({ type: "string", description: "faq id", example: "66f3bcaee566db722a044c62" }) + faqId: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "faq question", example: "نحوه ثبت نام چگونه است؟" }) + question?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "faq answer", example: "به آن صورت می باشد" }) + answer?: string; +} diff --git a/src/modules/faq/faq.controller.ts b/src/modules/faq/faq.controller.ts new file mode 100644 index 0000000..f52d464 --- /dev/null +++ b/src/modules/faq/faq.controller.ts @@ -0,0 +1,25 @@ +import { inject } from "inversify"; +import { controller, httpGet, queryParam } from "inversify-express-utils"; + +import { FaqService } from "./faq.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/faq") +@ApiTags("Faq") +class FaqController extends BaseController { + @inject(IOCTYPES.FaqService) faqService: FaqService; + + @ApiOperation("get all faqs") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("page", "the page of faq ==> value Faq | Seller | ProductTag | ProductDescription | Benefit | ShipmentProcess | Policy") + @httpGet("") + public async getAllFaqs(@queryParam("page") page: string) { + const data = await this.faqService.getAllFaqs(page); + return this.response(data); + } +} + +export { FaqController }; diff --git a/src/modules/faq/faq.repository.ts b/src/modules/faq/faq.repository.ts new file mode 100644 index 0000000..bf49ac4 --- /dev/null +++ b/src/modules/faq/faq.repository.ts @@ -0,0 +1,15 @@ +import { IFaq } from "./models/Abstraction/IFaq"; +import { FaqModel } from "./models/faq.model"; +import { BaseRepository } from "../../common/base/repository"; + +class FaqRepo extends BaseRepository { + constructor() { + super(FaqModel); + } +} + +function createFaqRepo(): FaqRepo { + return new FaqRepo(); +} + +export { FaqRepo, createFaqRepo }; diff --git a/src/modules/faq/faq.service.ts b/src/modules/faq/faq.service.ts new file mode 100644 index 0000000..72791c8 --- /dev/null +++ b/src/modules/faq/faq.service.ts @@ -0,0 +1,54 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { CreateFaqDTO } from "./DTO/createFaq.dto"; +import { UpdateFaqDTO } from "./DTO/updateFaq.dto"; +import { FaqRepo } from "./faq.repository"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class FaqService { + @inject(IOCTYPES.FaqRepo) faqRepo: FaqRepo; + + async getAllFaqs(page: string) { + const faqs = await this.faqRepo.model.find({ page }).sort({ createdAt: -1 }); + return { faqs }; + } + //############################## + //############################## + + async createFaq(faqDto: CreateFaqDTO) { + const faq = await this.faqRepo.model.create(faqDto); + return { + message: CommonMessage.Created, + faq, + }; + } + //############################## + //############################## + async updateFaq(updateFaqDto: UpdateFaqDTO) { + const updatedFaq = await this.faqRepo.model.findByIdAndUpdate(updateFaqDto.faqId, updateFaqDto, { new: true }); + return { + message: CommonMessage.Updated, + updatedFaq, + }; + } + //############################## + //############################## + async deleteFaq(faqId: string) { + if (!isValidObjectId(faqId)) throw new BadRequestError(CommonMessage.NotValidId); + + const deletedFaq = await this.faqRepo.model.findByIdAndDelete(faqId); + console.log(deletedFaq); + + if (!deletedFaq) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Deleted, + deletedFaq, + }; + } +} + +export { FaqService }; diff --git a/src/modules/faq/models/Abstraction/IFaq.ts b/src/modules/faq/models/Abstraction/IFaq.ts new file mode 100644 index 0000000..1499f00 --- /dev/null +++ b/src/modules/faq/models/Abstraction/IFaq.ts @@ -0,0 +1,7 @@ +import { PageEnum } from "../../../../common/enums/page.enum"; + +export interface IFaq { + question: string; + answer: string; + page: PageEnum; +} diff --git a/src/modules/faq/models/faq.model.ts b/src/modules/faq/models/faq.model.ts new file mode 100644 index 0000000..9ee93c4 --- /dev/null +++ b/src/modules/faq/models/faq.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { IFaq } from "./Abstraction/IFaq"; +import { PageEnum } from "../../../common/enums/page.enum"; + +const FaqSchema = new Schema( + { + question: { type: String, required: true }, + answer: { type: String, required: true }, + page: { type: String, enum: PageEnum, default: PageEnum.Faq }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const FaqModel = model("Faq", FaqSchema); + +export { FaqModel }; diff --git a/src/modules/fine/DTO/fine.dto.ts b/src/modules/fine/DTO/fine.dto.ts new file mode 100644 index 0000000..37766a4 --- /dev/null +++ b/src/modules/fine/DTO/fine.dto.ts @@ -0,0 +1,65 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { FineRuleListDTO } from "./fineRule.dto"; +import { ProductListDTO } from "../../product/DTO/product.dto"; + +export class FineDto { + @Expose() + product: string; + + @Expose() + seller: string; + + @Expose() + orderItem: string; + + @Expose() + reason: string; + + @Expose() + fine_rule: string; + + @Expose() + fine_amount: number; +} + +export class FineListDTO { + @Expose() + _id: string; + + @Expose() + orderItemRef: string; + + @Expose() + reasonRef: string; + + @Expose() + fine_amount: number; + + @Expose() + createdAt: Date; + + @Expose() + fineRuleDetails: FineRuleListDTO; + + @Expose() + reasonDetails: any; + + @Expose() + orderItemsDetails: any; + + @Expose() + shipmentItem: any; + + @Expose() + @Type(() => ProductListDTO) + product: ProductListDTO; + + public static transformFineList(data: any): FineListDTO { + const fineListDTO = plainToInstance(FineListDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + return fineListDTO; + } +} diff --git a/src/modules/fine/DTO/fineRule.dto.ts b/src/modules/fine/DTO/fineRule.dto.ts new file mode 100644 index 0000000..71d5b46 --- /dev/null +++ b/src/modules/fine/DTO/fineRule.dto.ts @@ -0,0 +1,29 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class FineRuleDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of fine", example: "تاخیر در تحویل" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the fine percentage of fine", example: "20" }) + fine_percentage: number; +} + +export class FineRuleListDTO { + @Expose() + _id: string; + + @Expose() + title: string; + + @Expose() + fine_percentage: number; +} diff --git a/src/modules/fine/DTO/role-update.dto.ts b/src/modules/fine/DTO/role-update.dto.ts new file mode 100644 index 0000000..25a4b30 --- /dev/null +++ b/src/modules/fine/DTO/role-update.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer"; +import { IsNumber, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class UpdateFineRuleDTO { + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "the title of fine", example: "تاخیر در تحویل" }) + title: string; + + @Expose() + @IsOptional() + @IsNumber() + @ApiProperty({ type: "number", description: "the fine percentage of fine", example: "20" }) + fine_percentage: number; +} diff --git a/src/modules/fine/fine.controller.ts b/src/modules/fine/fine.controller.ts new file mode 100644 index 0000000..f3697ed --- /dev/null +++ b/src/modules/fine/fine.controller.ts @@ -0,0 +1,46 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, queryParam, request } from "inversify-express-utils"; + +import { FineService } from "./fine.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; + +@controller("/fine") +@ApiTags("Fine") +class FineController extends BaseController { + @inject(IOCTYPES.FineService) fineService: FineService; + + //return all fine rules + + @ApiOperation("get list of fine rules") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/rules") + public async getFineRules() { + const data = await this.fineService.getFineRules(); + return this.response(data); + } + + @ApiOperation("get all fine ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of fines data") + @ApiQuery("page", "the page want to get") + @ApiAuth() + @httpGet("/fines", Guard.authSeller()) + public async getAllFinesBySeller(@request() req: Request, @queryParam("limit") limit: string, @queryParam("page") page: string) { + const seller = req.user as ISeller; + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.fineService.getAllFinesBySeller(seller?._id.toString(), queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, fineList: data.fineList }); + } +} + +export { FineController }; diff --git a/src/modules/fine/fine.service.ts b/src/modules/fine/fine.service.ts new file mode 100644 index 0000000..6f24e6d --- /dev/null +++ b/src/modules/fine/fine.service.ts @@ -0,0 +1,59 @@ +import { inject, injectable } from "inversify"; + +import { FineListDTO } from "./DTO/fine.dto"; +import { FineRuleDTO } from "./DTO/fineRule.dto"; +import { UpdateFineRuleDTO } from "./DTO/role-update.dto"; +import { FineRepo } from "./repository/fine.repository"; +import { FineRuleRepo } from "./repository/fineRule.repository"; +import { AdminFinesQueries, SellerFinesQueries } from "../../common/types/query.type"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class FineService { + @inject(IOCTYPES.FineRuleRepo) private fineRuleRepo: FineRuleRepo; + @inject(IOCTYPES.FineRepo) private fineRepo: FineRepo; + + async createFineRule(fineRuleDto: FineRuleDTO) { + const fineRule = await this.fineRuleRepo.model.create(fineRuleDto); + return { fineRule }; + } + + async getFineRules() { + const fineRules = await this.fineRuleRepo.model.find({ deleted: false }); + return { fineRules }; + } + + async updateFineRule(ruleId: string, updateDto: UpdateFineRuleDTO) { + const fineRule = await this.fineRuleRepo.model.findOneAndUpdate({ _id: ruleId }, updateDto, { new: true }); + return { fineRule }; + } + + async getRuleWithId(ruleId: string) { + const fineRule = await this.fineRuleRepo.findById(ruleId); + return { fineRule }; + } + + async deleteRule(ruleId: string) { + const fineRule = await this.fineRuleRepo.model.findOneAndUpdate({ _id: ruleId }, { deleted: true }, { new: true }); + return { fineRule }; + } + + async getAllFines(queries: AdminFinesQueries) { + const { docs, count } = await this.fineRepo.findAllForAdmin(queries); + const fineList = docs.map((doc: any) => FineListDTO.transformFineList(doc)); + + return { fineList, count }; + } + + async getAllFinesBySeller(sellerId: string, queries: SellerFinesQueries) { + const { docs, count } = await this.fineRepo.findAllBySeller(sellerId, queries); + const fineList = docs.map((doc: any) => FineListDTO.transformFineList(doc)); + console.log(fineList); + return { + fineList, + count, + }; + } +} + +export { FineService }; diff --git a/src/modules/fine/models/Abstraction/IFine.ts b/src/modules/fine/models/Abstraction/IFine.ts new file mode 100644 index 0000000..a6e9d2c --- /dev/null +++ b/src/modules/fine/models/Abstraction/IFine.ts @@ -0,0 +1,14 @@ +import { Types } from "mongoose"; + +import { OrderItemRefEnum, ReasonRefEnum } from "../fine.model"; + +export interface IFine { + product: Types.ObjectId; + seller: Types.ObjectId; + orderItem: Types.ObjectId; + orderItemRef: OrderItemRefEnum; + fine_rule: Types.ObjectId; + reason: Types.ObjectId; + reasonRef: ReasonRefEnum; + fine_amount: number; +} diff --git a/src/modules/fine/models/Abstraction/IFineRule.ts b/src/modules/fine/models/Abstraction/IFineRule.ts new file mode 100644 index 0000000..cbc0dae --- /dev/null +++ b/src/modules/fine/models/Abstraction/IFineRule.ts @@ -0,0 +1,5 @@ +export interface IFineRule { + title: string; + fine_percentage: number; + deleted: boolean; +} diff --git a/src/modules/fine/models/fine.model.ts b/src/modules/fine/models/fine.model.ts new file mode 100644 index 0000000..e8dae8a --- /dev/null +++ b/src/modules/fine/models/fine.model.ts @@ -0,0 +1,30 @@ +import { Schema, model } from "mongoose"; + +import { IFine } from "./Abstraction/IFine"; + +export enum ReasonRefEnum { + Cancel = "Cancel", + Return = "Return", +} + +export enum OrderItemRefEnum { + CancelOrderItem = "CancelOrderItem", + ReturnOrderItem = "ReturnOrderItem", +} + +const FineSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + orderItem: { type: Schema.Types.ObjectId, ref: "orderItemRef", required: true }, + orderItemRef: { type: String, enum: OrderItemRefEnum, required: true }, + fine_rule: { type: Schema.Types.ObjectId, ref: "FineRule", required: true }, + reason: { type: Schema.Types.ObjectId, refPath: "reasonRef", required: true }, + reasonRef: { type: String, enum: ReasonRefEnum, required: true }, + fine_amount: { type: Number, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const FineModel = model("Fine", FineSchema); + +export { FineModel }; diff --git a/src/modules/fine/models/fineRule.model.ts b/src/modules/fine/models/fineRule.model.ts new file mode 100644 index 0000000..0e260a1 --- /dev/null +++ b/src/modules/fine/models/fineRule.model.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { IFineRule } from "./Abstraction/IFineRule"; + +const FineRuleSchema = new Schema( + { + title: { type: String, required: true }, + fine_percentage: { type: Number, required: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const FineRuleModel = model("FineRule", FineRuleSchema); + +export { FineRuleModel }; diff --git a/src/modules/fine/repository/fine.repository.ts b/src/modules/fine/repository/fine.repository.ts new file mode 100644 index 0000000..18167b6 --- /dev/null +++ b/src/modules/fine/repository/fine.repository.ts @@ -0,0 +1,335 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { SellerFinesQueries } from "../../../common/types/query.type"; +import { IFine } from "../models/Abstraction/IFine"; +import { FineModel } from "../models/fine.model"; + +export class FineRepo extends BaseRepository { + constructor() { + super(FineModel); + } + + async findAllBySeller(sellerId: string, queries: SellerFinesQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + const docs = await this.model.aggregate([ + { + $match: { + seller: new Types.ObjectId(sellerId), + }, + }, + { + $lookup: { + from: "finerules", + localField: "fine_rule", + foreignField: "_id", + as: "fineRuleDetails", + }, + }, + { + $unwind: "$fineRuleDetails", + }, + { + $addFields: { + sellerDetails: { $arrayElemAt: ["$sellerDetails", 0] }, + }, + }, + { + $lookup: { + from: "cancelreasons", + localField: "reason", + foreignField: "_id", + as: "cancelReasonDetails", + }, + }, + { + $addFields: { + cancelReasonDetails: { $ifNull: ["$cancelReasonDetails", []] }, + }, + }, + { + $lookup: { + from: "returnreasons", + localField: "reason", + foreignField: "_id", + as: "returnReasonDetails", + }, + }, + { + $addFields: { + returnReasonDetails: { $ifNull: ["$returnReasonDetails", []] }, + }, + }, + { + $addFields: { + reasonDetails: { + $cond: { + if: { $eq: ["$reasonRef", "Cancel"] }, + then: { $arrayElemAt: ["$cancelReasonDetails", 0] }, + else: { $arrayElemAt: ["$returnReasonDetails", 0] }, + }, + }, + }, + }, + { + $unset: ["cancelReasonDetails", "returnReasonDetails"], + }, + { + $lookup: { + from: "cancelorderitems", + localField: "orderItem", + foreignField: "_id", + as: "cancelOrderItemDetails", + }, + }, + { + $addFields: { + cancelOrderItemDetails: { $ifNull: ["$cancelOrderItemDetails", []] }, + }, + }, + { + $lookup: { + from: "returnorderitems", + localField: "orderItem", + foreignField: "_id", + as: "returnOrderItemDetails", + }, + }, + { + $addFields: { + returnOrderItemDetails: { $ifNull: ["$returnOrderItemDetails", []] }, + }, + }, + { + $addFields: { + orderItemsDetails: { + $cond: { + if: { $eq: ["$orderItemRef", "CancelOrderItem"] }, + then: { $arrayElemAt: ["$cancelOrderItemDetails", 0] }, + else: { $arrayElemAt: ["$returnOrderItemDetails", 0] }, + }, + }, + }, + }, + { + $unset: ["cancelOrderItemDetails", "returnOrderItemDetails"], + }, + { + $lookup: { + from: "orderitems", + localField: "orderItemsDetails.orderItem", + foreignField: "_id", + as: "orderItemsDetails.orderItem", + }, + }, + { + $unwind: "$orderItemsDetails.orderItem", + }, + { + $addFields: { + shipmentItem: { + $arrayElemAt: ["$orderItemsDetails.orderItem.shipmentItems", 0], + }, + }, + }, + { + $lookup: { + from: "products", + localField: "shipmentItem.product", + foreignField: "_id", + as: "product", + }, + }, + { + $addFields: { + product: { $arrayElemAt: ["$product", 0] }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + console.dir(docs[0], { depth: null }); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async findAllForAdmin(queries: SellerFinesQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + const docs = await this.model.aggregate([ + { + $lookup: { + from: "finerules", + localField: "fine_rule", + foreignField: "_id", + as: "fineRuleDetails", + }, + }, + { + $unwind: "$fineRuleDetails", + }, + { + $addFields: { + sellerDetails: { $arrayElemAt: ["$sellerDetails", 0] }, + }, + }, + { + $lookup: { + from: "cancelreasons", + localField: "reason", + foreignField: "_id", + as: "cancelReasonDetails", + }, + }, + { + $addFields: { + cancelReasonDetails: { $ifNull: ["$cancelReasonDetails", []] }, + }, + }, + { + $lookup: { + from: "returnreasons", + localField: "reason", + foreignField: "_id", + as: "returnReasonDetails", + }, + }, + { + $addFields: { + returnReasonDetails: { $ifNull: ["$returnReasonDetails", []] }, + }, + }, + { + $addFields: { + reasonDetails: { + $cond: { + if: { $eq: ["$reasonRef", "Cancel"] }, + then: { $arrayElemAt: ["$cancelReasonDetails", 0] }, + else: { $arrayElemAt: ["$returnReasonDetails", 0] }, + }, + }, + }, + }, + { + $unset: ["cancelReasonDetails", "returnReasonDetails"], + }, + { + $lookup: { + from: "cancelorderitems", + localField: "orderItem", + foreignField: "_id", + as: "cancelOrderItemDetails", + }, + }, + { + $addFields: { + cancelOrderItemDetails: { $ifNull: ["$cancelOrderItemDetails", []] }, + }, + }, + { + $lookup: { + from: "returnorderitems", + localField: "orderItem", + foreignField: "_id", + as: "returnOrderItemDetails", + }, + }, + { + $addFields: { + returnOrderItemDetails: { $ifNull: ["$returnOrderItemDetails", []] }, + }, + }, + { + $addFields: { + orderItemsDetails: { + $cond: { + if: { $eq: ["$orderItemRef", "CancelOrderItem"] }, + then: { $arrayElemAt: ["$cancelOrderItemDetails", 0] }, + else: { $arrayElemAt: ["$returnOrderItemDetails", 0] }, + }, + }, + }, + }, + { + $unset: ["cancelOrderItemDetails", "returnOrderItemDetails"], + }, + { + $lookup: { + from: "orderitems", + localField: "orderItemsDetails.orderItem", + foreignField: "_id", + as: "orderItemsDetails.orderItem", + }, + }, + { + $unwind: "$orderItemsDetails.orderItem", + }, + { + $addFields: { + shipmentItem: { + $arrayElemAt: ["$orderItemsDetails.orderItem.shipmentItems", 0], + }, + }, + }, + { + $lookup: { + from: "products", + localField: "shipmentItem.product", + foreignField: "_id", + as: "product", + }, + }, + { + $addFields: { + product: { $arrayElemAt: ["$product", 0] }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + console.dir(docs[0], { depth: null }); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } +} + +export function createFineRepo(): FineRepo { + return new FineRepo(); +} diff --git a/src/modules/fine/repository/fineRule.repository.ts b/src/modules/fine/repository/fineRule.repository.ts new file mode 100644 index 0000000..e1a720f --- /dev/null +++ b/src/modules/fine/repository/fineRule.repository.ts @@ -0,0 +1,17 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IFineRule } from "../models/Abstraction/IFineRule"; +import { FineRuleModel } from "../models/fineRule.model"; + +export class FineRuleRepo extends BaseRepository { + constructor() { + super(FineRuleModel); + } + + async findFineRuleByCancelReasonId(cancelReasonId: string) { + return this.model.findOne({ cancel_reason: cancelReasonId }).populate("cancel_reason"); + } +} + +export function createFineRuleRepo(): FineRuleRepo { + return new FineRuleRepo(); +} diff --git a/src/modules/job/DTO/createJob.dto.ts b/src/modules/job/DTO/createJob.dto.ts new file mode 100644 index 0000000..0c554bc --- /dev/null +++ b/src/modules/job/DTO/createJob.dto.ts @@ -0,0 +1,32 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { JobType } from "../models/Abstraction/IJob"; + +export class CreateJobDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of job", example: "حسابدار" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of job", example: "حسابدار مسلط به هلو" }) + description: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "location of job", example: "اراک" }) + location: string; + + @Expose() + @IsNotEmpty() + @IsEnum(JobType) + @IsString() + @ApiProperty({ type: "string", description: "title of job", example: "fullTime | partTime | contract" }) + type: JobType; +} diff --git a/src/modules/job/DTO/resume.dto.ts b/src/modules/job/DTO/resume.dto.ts new file mode 100644 index 0000000..57785ad --- /dev/null +++ b/src/modules/job/DTO/resume.dto.ts @@ -0,0 +1,48 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId, IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { JobModel } from "../models/job.model"; + +export class ResumeDTO { + @Expose() + @IsNotEmpty() + @IsString() + @Length(24, 24) + @IsValidId(JobModel) + @ApiProperty({ type: "string", description: "the id of the job", example: "66f3bcaee566db722a044c62" }) + job: string; + + @Expose() + @IsNotEmpty() + @IsString() + @Length(8) + @ApiProperty({ type: "string", description: "full name of applicant", example: "محمد محمدی" }) + fullName: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "father name of applicant", example: "مجید محمدی" }) + fatherName: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the place of birth ", example: "اراک" }) + placeOfBirth: string; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "the id of the job", example: "66f3bcaee566db722a044c62" }) + birthday: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the url of the resume that uploaded", example: "http://cdn.com" }) + resumeUrl: string; +} diff --git a/src/modules/job/job.controller.ts b/src/modules/job/job.controller.ts new file mode 100644 index 0000000..153b89c --- /dev/null +++ b/src/modules/job/job.controller.ts @@ -0,0 +1,64 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody } from "inversify-express-utils"; + +import { JobService } from "./job.service"; +import { HttpStatus } from "../../common"; +import { ResumeDTO } from "./DTO/resume.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiFile, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; + +@controller("/jobs") +@ApiTags("Jobs") +class JobController extends BaseController { + @inject(IOCTYPES.JobService) jobService: JobService; + + @ApiOperation("get all job") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @httpGet("") + public async getAllJob(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const data = await this.jobService.getAvailableJobs(queries); + const { pager } = this.paginate(data.count); + return this.response({ pager, jobs: data.jobs }); + } + + @ApiOperation("send resume for a job") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(ResumeDTO) + @httpPost("/resume", ValidationMiddleware.validateInput(ResumeDTO)) + public async submitResume(@requestBody() resumeDto: ResumeDTO) { + const data = await this.jobService.submitResume(resumeDto); + return this.response(data, HttpStatus.Created); + } + + //media uploader + @ApiOperation("Upload a resume image") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("resume") + @httpPost("/upload/single", UploadService.single("resume", "resume-file")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + }, + }; + return this.response(data, HttpStatus.Accepted); + } +} + +export { JobController }; diff --git a/src/modules/job/job.service.ts b/src/modules/job/job.service.ts new file mode 100644 index 0000000..a7a5423 --- /dev/null +++ b/src/modules/job/job.service.ts @@ -0,0 +1,88 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { CreateJobDTO } from "./DTO/createJob.dto"; +import { ResumeDTO } from "./DTO/resume.dto"; +import { JobRepo } from "./repository/job.repo"; +import { ResumeRepo } from "./repository/resume.repo"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class JobService { + @inject(IOCTYPES.JobRepo) jobRepo: JobRepo; + @inject(IOCTYPES.ResumeRepo) resumeRepo: ResumeRepo; + + //##################################### + //##################################### + + async getAvailableJobs(queries: { limit: number; page: number }) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const count = await this.resumeRepo.model.countDocuments(); + const jobs = await this.jobRepo.model.find().skip(skip).limit(limit); + + return { jobs, count }; + } + //##################################### + //##################################### + async createJob(jobDto: CreateJobDTO) { + const newJob = await this.jobRepo.model.create({ + ...jobDto, + }); + return { + message: CommonMessage.Created, + newJob, + }; + } + //##################################### + //##################################### + async submitResume(resumeDto: ResumeDTO) { + const resume = await this.resumeRepo.model.create({ + ...resumeDto, + }); + return { + message: CommonMessage.Created, + resume, + }; + } + //##################################### + //##################################### + async getResumesForJob(jobId: string) { + if (!isValidObjectId(jobId)) throw new BadRequestError(CommonMessage.NotValidId); + + const resumes = await this.resumeRepo.model.find({ job: jobId }).populate("job"); + return { + resumes, + }; + } + //##################################### + //##################################### + async getAllResumes(queries: { limit: number; page: number }) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const count = await this.resumeRepo.model.countDocuments(); + const resumes = await this.resumeRepo.model.find().populate("job").skip(skip).limit(limit); + + return { resumes, count }; + } + //##################################### + //##################################### + async deleteJob(jobId: string) { + if (!isValidObjectId(jobId)) throw new BadRequestError(CommonMessage.NotValidId); + + const deleteJob = await this.jobRepo.model.findByIdAndDelete(jobId); + if (!deleteJob) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Deleted, + }; + } +} + +export { JobService }; diff --git a/src/modules/job/models/Abstraction/IJob.ts b/src/modules/job/models/Abstraction/IJob.ts new file mode 100644 index 0000000..9a7b90c --- /dev/null +++ b/src/modules/job/models/Abstraction/IJob.ts @@ -0,0 +1,14 @@ +export enum JobType { + FullTime = "fullTime", + PartTime = "partTime", + Contract = "contract", +} + +export interface IJob { + title: string; + description: string; + location: string; + type: JobType; + // salaryRange:string; + // skillsRequired:string[] +} diff --git a/src/modules/job/models/Abstraction/IResume.ts b/src/modules/job/models/Abstraction/IResume.ts new file mode 100644 index 0000000..a4d0733 --- /dev/null +++ b/src/modules/job/models/Abstraction/IResume.ts @@ -0,0 +1,14 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IResume { + job: Types.ObjectId; + // applicant: Types.ObjectId; + fullName: string; + fatherName: string; + placeOfBirth: string; + birthday: string; + resumeUrl: string; + status: StatusEnum; +} diff --git a/src/modules/job/models/job.model.ts b/src/modules/job/models/job.model.ts new file mode 100644 index 0000000..67419d4 --- /dev/null +++ b/src/modules/job/models/job.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { IJob, JobType } from "./Abstraction/IJob"; + +const JobSchema = new Schema( + { + title: { type: String, required: true }, + description: { type: String, required: true }, + location: { type: String, required: true }, + type: { type: String, enum: JobType, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const JobModel = model("Job", JobSchema); + +export { JobModel }; diff --git a/src/modules/job/models/resume.model.ts b/src/modules/job/models/resume.model.ts new file mode 100644 index 0000000..d4a2379 --- /dev/null +++ b/src/modules/job/models/resume.model.ts @@ -0,0 +1,21 @@ +import { Schema, model } from "mongoose"; + +import { IResume } from "./Abstraction/IResume"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const ResumeSchema = new Schema( + { + job: { type: Schema.Types.ObjectId, ref: "Job", required: true }, + fullName: { type: String, ref: "Job", required: true }, + fatherName: { type: String, ref: "Job", required: true }, + placeOfBirth: { type: String, ref: "Job", required: true }, + birthday: { type: String, ref: "Job", required: true }, + resumeUrl: { type: String, required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ResumeModel = model("Resume", ResumeSchema); + +export { ResumeModel }; diff --git a/src/modules/job/repository/job.repo.ts b/src/modules/job/repository/job.repo.ts new file mode 100644 index 0000000..7f099b0 --- /dev/null +++ b/src/modules/job/repository/job.repo.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IJob } from "../models/Abstraction/IJob"; +import { JobModel } from "../models/job.model"; + +class JobRepo extends BaseRepository { + constructor() { + super(JobModel); + } +} + +function createJobRepo(): JobRepo { + return new JobRepo(); +} + +export { JobRepo, createJobRepo }; diff --git a/src/modules/job/repository/resume.repo.ts b/src/modules/job/repository/resume.repo.ts new file mode 100644 index 0000000..5f03279 --- /dev/null +++ b/src/modules/job/repository/resume.repo.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IResume } from "../models/Abstraction/IResume"; +import { ResumeModel } from "../models/resume.model"; + +class ResumeRepo extends BaseRepository { + constructor() { + super(ResumeModel); + } +} + +function createResumeRepo(): ResumeRepo { + return new ResumeRepo(); +} + +export { ResumeRepo, createResumeRepo }; diff --git a/src/modules/landing/landing.controller.ts b/src/modules/landing/landing.controller.ts new file mode 100644 index 0000000..c392b74 --- /dev/null +++ b/src/modules/landing/landing.controller.ts @@ -0,0 +1,28 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, request } from "inversify-express-utils"; + +import { LandingService } from "./landing.service"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/landing") +@ApiTags("Landing") +class LandingController extends BaseController { + @inject(IOCTYPES.LandingService) landingService: LandingService; + + @ApiOperation("get landing page items") + @ApiResponse("successful") + @ApiAuth() + @httpGet("", Guard.authOptional()) + public async landingPageItems(@request() req: Request) { + const user = req.user as IUser; + const data = await this.landingService.getLandingPageItems(user?._id.toString()); + return this.response(data); + } +} + +export { LandingController }; diff --git a/src/modules/landing/landing.service.ts b/src/modules/landing/landing.service.ts new file mode 100644 index 0000000..00b92b0 --- /dev/null +++ b/src/modules/landing/landing.service.ts @@ -0,0 +1,75 @@ +import { inject, injectable } from "inversify"; + +import { IOCTYPES } from "../../IOC/ioc.types"; +import { BannerRepository, SliderRepository } from "../admin/repository/media"; +import { CategoryRepository } from "../category/category.repository"; +import { MediaService } from "../media/media.service"; +import { ProductDTO } from "../product/DTO/product.dto"; +import { IncredibleOffersRepo } from "../product/Repository/incredibleOffers"; +import { ProductRepository } from "../product/Repository/product"; +import { ProductVariantRepository } from "../product/Repository/productVarinat"; + +@injectable() +class LandingService { + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.IncredibleOffersRepo) incredibleOffersRepo: IncredibleOffersRepo; + @inject(IOCTYPES.MediaService) mediaService: MediaService; + @inject(IOCTYPES.CategoryRepository) categoryRepo: CategoryRepository; + @inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository; + @inject(IOCTYPES.SliderRepository) sliderRepo: SliderRepository; + @inject(IOCTYPES.BannerRepository) bannerRepo: BannerRepository; + + async getLandingPageItems(userId?: string) { + return { + popularProducts: await this.getPopularProducts(userId), + incredibleOffers: await this.getIncredibleProductOffers(userId), + latestProducts: await this.getLatestProducts(userId), + topCategory: await this.getTopCategory(), + sliders: { desktop: await this.mediaService.getSliderForDesktop(), mobile: await this.mediaService.getSliderForMobile() }, + banners: await this.mediaService.getBanners(), + }; + } + + async getIncredibleProductOffers(userId?: string) { + const docs = await this.incredibleOffersRepo.getIncredibleOffers(userId); + return docs.map((doc) => ProductDTO.transformProduct(doc.product)); + // const products = await this.productRepo.getIncredibleOffers(userId); + } + + async getLatestProducts(userId?: string) { + return this.productRepo.getLatest(userId); + } + + async getPopularProducts(userId?: string) { + return this.productRepo.getPopular(userId); + } + + async getTopCategory() { + return this.categoryRepo.model.aggregate([ + { + $match: { + parent: null, + deleted: false, + }, + }, + { + $addFields: { + url: { $concat: ["/category/", { $toString: "$title_en" }, "/search"] }, + }, + }, + { + $project: { + _id: 1, + description: 1, + icon: 1, + imageUrl: 1, + title_en: 1, + title_fa: 1, + url: 1, + }, + }, + ]); + } +} + +export { LandingService }; diff --git a/src/modules/learning/DTO/createLearning.dto.ts b/src/modules/learning/DTO/createLearning.dto.ts new file mode 100644 index 0000000..e843806 --- /dev/null +++ b/src/modules/learning/DTO/createLearning.dto.ts @@ -0,0 +1,49 @@ +import { Expose, plainToClass } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ILearning } from "../model/Abstractions/ILearning"; +import { LearningCategoryModel } from "../model/learningCategory.model"; + +export class CreateLearningDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of learning", example: "عنوان ۱" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "description of learning", example: "توضیحات ویدیو" }) + description: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "video url of learning", example: "https://loremflickr.com/454/340" }) + videoUrl: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "cover url of learning", example: "https://loremflickr.com/454/340" }) + cover?: string; + + @Expose() + @IsNotEmpty() + @IsValidId(LearningCategoryModel) + @ApiProperty({ type: "string", description: "id of learning category", example: "66ed83563858f185c68449ff" }) + category: string; + + public static transformLearning(data: ILearning): CreateLearningDTO { + const LearningDto = plainToClass(CreateLearningDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return LearningDto; + } +} diff --git a/src/modules/learning/DTO/createLearningCategory.dto.ts b/src/modules/learning/DTO/createLearningCategory.dto.ts new file mode 100644 index 0000000..745007d --- /dev/null +++ b/src/modules/learning/DTO/createLearningCategory.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateLearningCategoryDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of learning", example: "عنوان ۱" }) + title: string; +} diff --git a/src/modules/learning/DTO/createLearningProgress.dto.ts b/src/modules/learning/DTO/createLearningProgress.dto.ts new file mode 100644 index 0000000..33a36f8 --- /dev/null +++ b/src/modules/learning/DTO/createLearningProgress.dto.ts @@ -0,0 +1,14 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { LearningModel } from "../model/learning.model"; + +export class CreateLearningProgressDTO { + @Expose() + @IsNotEmpty() + @IsValidId(LearningModel) + @ApiProperty({ type: "string", description: "id of learning video", example: "66ed83563858f185c68449ff" }) + videoId: string; +} diff --git a/src/modules/learning/DTO/updateLearning.dto.ts b/src/modules/learning/DTO/updateLearning.dto.ts new file mode 100644 index 0000000..cf5f4ad --- /dev/null +++ b/src/modules/learning/DTO/updateLearning.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer"; +import { IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class UpdateLearningDTO { + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "title of learning", example: "عنوان ۱" }) + title: string; + + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "description of learning", example: "توضیحات ویدیو" }) + description: string; +} diff --git a/src/modules/learning/DTO/updateLearningCategory.dto.ts b/src/modules/learning/DTO/updateLearningCategory.dto.ts new file mode 100644 index 0000000..cb6e729 --- /dev/null +++ b/src/modules/learning/DTO/updateLearningCategory.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from "class-transformer"; +import { IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class UpdateLearningCategoryDTO { + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "title of learning category", example: "عنوان ۱" }) + title: string; +} diff --git a/src/modules/learning/learning.controller.ts b/src/modules/learning/learning.controller.ts new file mode 100644 index 0000000..56ce764 --- /dev/null +++ b/src/modules/learning/learning.controller.ts @@ -0,0 +1,76 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { LearningService } from "./learning.service"; +import { HttpStatus } from "../../common"; +import { CreateLearningProgressDTO } from "./DTO/createLearningProgress.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; + +@controller("/learning") +@ApiTags("Learning") +export class LearningController extends BaseController { + @inject(IOCTYPES.LearningService) learningService: LearningService; + + @ApiOperation("get a list of learning") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("") + public async getAllLearning() { + const data = await this.learningService.getAllLearning(); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("get a list of learning") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/seller", Guard.authSeller()) + public async getAllLearningByCategories(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.learningService.getAllLearningByCategory(seller._id.toString()); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("get a list of learning category") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/category") + public async getAllLearningCategory() { + const data = await this.learningService.getAllLearningCategory(); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("get a learning") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "the learning id", true) + @httpGet("/:id") + public async getLearning(@requestParam("id") learningId: string) { + const data = await this.learningService.getById(learningId); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("learning progress for seller ===> need to login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(CreateLearningProgressDTO) + @ApiAuth() + @httpPost("/progress", Guard.authSeller(), ValidationMiddleware.validateInput(CreateLearningProgressDTO)) + public async sellerPanel(@request() req: Request, @requestBody() createLearningProgressDto: CreateLearningProgressDTO) { + const seller = req.user as ISeller; + const data = await this.learningService.createLearningProgress(seller._id.toString(), createLearningProgressDto.videoId); + return this.response(data); + } + + @ApiOperation("get a list of learning video watched") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "the category id", true) + @ApiAuth() + @httpGet("/:id/seller", Guard.authSeller()) + public async getVideoWatchedStatus(@request() req: Request, @requestParam("id") catId: string) { + const seller = req.user as ISeller; + const data = await this.learningService.getLearningProgressWatched(seller._id.toString(), catId); + return this.response(data, HttpStatus.Ok); + } +} diff --git a/src/modules/learning/learning.service.ts b/src/modules/learning/learning.service.ts new file mode 100644 index 0000000..4fc64ac --- /dev/null +++ b/src/modules/learning/learning.service.ts @@ -0,0 +1,140 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { CreateLearningDTO } from "./DTO/createLearning.dto"; +import { CreateLearningCategoryDTO } from "./DTO/createLearningCategory.dto"; +import { UpdateLearningDTO } from "./DTO/updateLearning.dto"; +import { UpdateLearningCategoryDTO } from "./DTO/updateLearningCategory.dto"; +import { LearningRepo } from "./repository/learning"; +import { LearningCategoryRepo } from "./repository/learningCategory"; +import { LearningProgressRepo } from "./repository/learningProgress"; +import { CommonMessage, SellerMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class LearningService { + @inject(IOCTYPES.LearningRepo) learningRepo: LearningRepo; + @inject(IOCTYPES.LearningProgressRepo) learningProgressRepo: LearningProgressRepo; + @inject(IOCTYPES.LearningCategoryRepo) learningCategoryRepo: LearningCategoryRepo; + + async createLearning(createDto: CreateLearningDTO) { + const category = await this.learningCategoryRepo.model.findOne({ _id: createDto.category, deleted: false }); + if (!category) { + throw new BadRequestError(CommonMessage.NotFoundById); + } + const learning = await this.learningRepo.model.create({ + ...createDto, + category, + }); + + return { + message: CommonMessage.Created, + learning, + }; + } + + async createLearningCategory(createDto: CreateLearningCategoryDTO) { + const learningCategory = await this.learningCategoryRepo.model.create({ + ...createDto, + }); + + return { + message: CommonMessage.Created, + learningCategory, + }; + } + + async getAllLearning() { + const learning = await this.learningRepo.model.find({ deleted: false }).populate("category"); + return { learning }; + } + + async getAllLearningByCategory(sellerId: string) { + const learning = await this.learningRepo.getVideosGroupByCategories(sellerId); + return { learning }; + } + + async getAllLearningCategory() { + const learningCategory = await this.learningCategoryRepo.model.find({ + deleted: false, + }); + return { learningCategory }; + } + + async getById(learningId: string) { + if (!isValidObjectId(learningId)) throw new BadRequestError(CommonMessage.NotValidId); + const learning = await this.learningRepo.model.findById(learningId).populate("category"); + if (!learning) throw new BadRequestError(CommonMessage.NotFoundById); + return { + learning, + }; + } + + async update(learningId: string, updateDto: UpdateLearningDTO) { + console.log(learningId); + if (!isValidObjectId(learningId)) throw new BadRequestError(CommonMessage.NotValidId); + const learning = await this.learningRepo.model.findByIdAndUpdate(learningId, updateDto, { new: true }); + console.log(learning); + if (!learning) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Updated, + learning, + }; + } + + async updateCategory(catId: string, updateDto: UpdateLearningCategoryDTO) { + if (!isValidObjectId(catId)) throw new BadRequestError(CommonMessage.NotValidId); + const learningCategory = await this.learningCategoryRepo.model.findByIdAndUpdate(catId, updateDto, { new: true }); + console.log(learningCategory); + if (!learningCategory) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Updated, + learningCategory, + }; + } + + async delete(learningId: string) { + if (!isValidObjectId(learningId)) throw new BadRequestError(CommonMessage.NotValidId); + await this.learningRepo.model.findByIdAndUpdate(learningId, { deleted: true }); + return { + message: CommonMessage.Deleted, + }; + } + + async createLearningProgress(sellerId: string, videoId: string) { + const learningVideo = await this.learningRepo.findById(videoId); + if (!learningVideo) { + throw new BadRequestError(CommonMessage.NotFoundById); + } + + const learningProgress = await this.learningProgressRepo.model.findOne({ + sellerId, + videoId, + }); + + if (learningProgress) throw new BadRequestError(SellerMessage.VideoWasWached); + + const newLearningProgress = await this.learningProgressRepo.model.create({ + sellerId, + videoId: learningVideo._id, + watchedAt: new Date(), + }); + return { + message: CommonMessage.Created, + newLearningProgress, + }; + } + + async getLearningProgressBySellerForAdmin() { + const progress = await this.learningProgressRepo.getLearningProgressBySellers(); + console.log(progress); + return { progress }; + } + + async getLearningProgressWatched(sellerId: string, catId: string) { + const progress = await this.learningRepo.getVideosWithWatchStatus(sellerId, catId); + console.log(progress); + return { progress }; + } +} diff --git a/src/modules/learning/model/Abstractions/ILearning.ts b/src/modules/learning/model/Abstractions/ILearning.ts new file mode 100644 index 0000000..56d1b53 --- /dev/null +++ b/src/modules/learning/model/Abstractions/ILearning.ts @@ -0,0 +1,10 @@ +import { Schema } from "mongoose"; + +export interface ILearning { + title: string; + description: string; + videoUrl: string; + cover: string; + category: Schema.Types.ObjectId; + deleted: boolean; +} diff --git a/src/modules/learning/model/Abstractions/ILearningCategory.ts b/src/modules/learning/model/Abstractions/ILearningCategory.ts new file mode 100644 index 0000000..bf420fd --- /dev/null +++ b/src/modules/learning/model/Abstractions/ILearningCategory.ts @@ -0,0 +1,4 @@ +export interface ILearningCategory { + title: string; + deleted: boolean; +} diff --git a/src/modules/learning/model/Abstractions/ILearningProgress.ts b/src/modules/learning/model/Abstractions/ILearningProgress.ts new file mode 100644 index 0000000..96f388d --- /dev/null +++ b/src/modules/learning/model/Abstractions/ILearningProgress.ts @@ -0,0 +1,7 @@ +import { Schema } from "mongoose"; + +export interface ILearningProgress { + sellerId: Schema.Types.ObjectId; + videoId: Schema.Types.ObjectId; + watchedAt?: Date; +} diff --git a/src/modules/learning/model/learning.model.ts b/src/modules/learning/model/learning.model.ts new file mode 100644 index 0000000..c0cb7bb --- /dev/null +++ b/src/modules/learning/model/learning.model.ts @@ -0,0 +1,23 @@ +import { Schema, model } from "mongoose"; + +import { ILearning } from "./Abstractions/ILearning"; + +const LearningSchema = new Schema( + { + title: { type: String, required: true }, + description: { type: String, required: true }, + videoUrl: { type: String, required: true }, + cover: { type: String, required: true }, + category: { type: Schema.Types.ObjectId, ref: "LearningCategory", required: true }, + deleted: { type: Boolean, default: false }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); + +const LearningModel = model("Learning", LearningSchema); + +export { LearningModel }; diff --git a/src/modules/learning/model/learningCategory.model.ts b/src/modules/learning/model/learningCategory.model.ts new file mode 100644 index 0000000..88c2b2c --- /dev/null +++ b/src/modules/learning/model/learningCategory.model.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +import { ILearningCategory } from "./Abstractions/ILearningCategory"; + +const LearningCategorySchema = new Schema( + { + title: { type: String, required: true, unique: true }, + deleted: { type: Boolean, default: false }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); + +const LearningCategoryModel = model("LearningCategory", LearningCategorySchema); + +export { LearningCategoryModel }; diff --git a/src/modules/learning/model/learningProgress.model.ts b/src/modules/learning/model/learningProgress.model.ts new file mode 100644 index 0000000..a4c20ab --- /dev/null +++ b/src/modules/learning/model/learningProgress.model.ts @@ -0,0 +1,23 @@ +import { Schema, model } from "mongoose"; + +import { ILearningProgress } from "./Abstractions/ILearningProgress"; + +const LearningProgressSchema = new Schema( + { + sellerId: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + videoId: { type: Schema.Types.ObjectId, ref: "Learning", required: true }, + watchedAt: { type: Date }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); + +// Defining a unique compound index for sellerId and videoId +LearningProgressSchema.index({ sellerId: 1, videoId: 1 }, { unique: true }); + +const LearningProgressModel = model("LearningProgress", LearningProgressSchema); + +export { LearningProgressModel }; diff --git a/src/modules/learning/repository/learning.ts b/src/modules/learning/repository/learning.ts new file mode 100644 index 0000000..df4febd --- /dev/null +++ b/src/modules/learning/repository/learning.ts @@ -0,0 +1,127 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { ILearning } from "../model/Abstractions/ILearning"; +import { LearningModel } from "../model/learning.model"; + +export class LearningRepo extends BaseRepository { + constructor() { + super(LearningModel); + } + + async getVideosWithWatchStatus(sellerId: string, catId: string) { + const result = await LearningModel.aggregate([ + { + $match: { + category: catId, + deleted: false, + }, + }, + { + $lookup: { + from: "learningprogresses", + let: { seller: new Types.ObjectId(sellerId), videoId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$sellerId", "$$seller"] }, { $eq: ["$videoId", "$$videoId"] }], + }, + }, + }, + ], + as: "watchStatus", + }, + }, + { + $addFields: { + isWatched: { $gt: [{ $size: { $ifNull: ["$watchStatus", []] } }, 0] }, + }, + }, + { + $project: { + _id: 1, + title: 1, + description: 1, + videoUrl: 1, + cover: 1, + isWatched: 1, + }, + }, + ]); + + return result; + } + + async getVideosGroupByCategories(sellerId: string) { + const result = await LearningModel.aggregate([ + { + $match: { + deleted: false, + }, + }, + { + $lookup: { + from: "learningprogresses", + let: { seller: new Types.ObjectId(sellerId), videoId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$sellerId", "$$seller"] }, { $eq: ["$videoId", "$$videoId"] }], + }, + }, + }, + ], + as: "watchStatus", + }, + }, + { + $addFields: { + isWatched: { $gt: [{ $size: { $ifNull: ["$watchStatus", []] } }, 0] }, + }, + }, + { + $lookup: { + from: "learningcategories", + localField: "category", + foreignField: "_id", + as: "categoryDetails", + }, + }, + { + $unwind: "$categoryDetails", + }, + { + $group: { + _id: "$category", + categoryTitle: { $first: "$categoryDetails.title" }, + videos: { + $push: { + _id: "$_id", + title: "$title", + cover: "$cover", + description: "$description", + videoUrl: "$videoUrl", + isWatched: "$isWatched", + }, + }, + }, + }, + { + $project: { + _id: 0, + category: "$_id", + categoryTitle: 1, + videos: 1, + }, + }, + ]); + + return result; + } +} + +export function CreateLearningRepo(): LearningRepo { + return new LearningRepo(); +} diff --git a/src/modules/learning/repository/learningCategory.ts b/src/modules/learning/repository/learningCategory.ts new file mode 100644 index 0000000..f2f421a --- /dev/null +++ b/src/modules/learning/repository/learningCategory.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ILearningCategory } from "../model/Abstractions/ILearningCategory"; +import { LearningCategoryModel } from "../model/learningCategory.model"; + +export class LearningCategoryRepo extends BaseRepository { + constructor() { + super(LearningCategoryModel); + } +} + +export function CreateLearningCategoryRepo(): LearningCategoryRepo { + return new LearningCategoryRepo(); +} diff --git a/src/modules/learning/repository/learningProgress.ts b/src/modules/learning/repository/learningProgress.ts new file mode 100644 index 0000000..3efb1ee --- /dev/null +++ b/src/modules/learning/repository/learningProgress.ts @@ -0,0 +1,105 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ILearningProgress } from "../model/Abstractions/ILearningProgress"; +import { LearningProgressModel } from "../model/learningProgress.model"; + +export class LearningProgressRepo extends BaseRepository { + constructor() { + super(LearningProgressModel); + } + + async getLearningProgressBySellers() { + const result = await LearningProgressModel.aggregate([ + { + $lookup: { + from: "learnings", + localField: "videoId", + foreignField: "_id", + as: "videoDetails", + }, + }, + { + $lookup: { + from: "sellers", + localField: "sellerId", + foreignField: "_id", + as: "sellerDetails", + }, + }, + { + $lookup: { + from: "shops", + localField: "sellerDetails._id", + foreignField: "owner", + as: "shopDetails", + }, + }, + { + $unwind: "$shopDetails", + }, + { + $addFields: { + "sellerDetails.shopName": "$shopDetails.shopName", + "sellerDetails.shopId": "$shopDetails._id", + }, + }, + { + $project: { + _id: 0, + sellerId: 1, + videoId: 1, + watchedAt: 1, + videoDetails: { $arrayElemAt: ["$videoDetails", 0] }, + sellerDetails: { $arrayElemAt: ["$sellerDetails", 0] }, + }, + }, + { + $group: { + _id: "$sellerId", + sellerDetails: { $first: "$sellerDetails" }, + watchedVideos: { + $push: { + videoId: "$videoId", + videoDetails: "$videoDetails", + watchedAt: "$watchedAt", + }, + }, + }, + }, + { + $lookup: { + from: "learnings", + pipeline: [], + as: "allVideos", + }, + }, + { + $project: { + sellerDetails: { + _id: 1, + fullName: 1, + shopName: 1, + shopId: 1, + }, + watchedVideos: 1, + unwatchedVideos: { + $filter: { + input: "$allVideos", + as: "video", + cond: { + $not: { + $in: ["$$video._id", "$watchedVideos.videoId"], + }, + }, + }, + }, + }, + }, + ]); + console.log(result); + return result; + } +} + +export function CreateLearningProgressRepo(): LearningProgressRepo { + return new LearningProgressRepo(); +} diff --git a/src/modules/media/DTO/display.dto.ts b/src/modules/media/DTO/display.dto.ts new file mode 100644 index 0000000..1c16f0e --- /dev/null +++ b/src/modules/media/DTO/display.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional } from "class-validator"; + +import { DisplayLocations } from "../../../common/enums/media.enum"; + +export class DisplayQueryDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsEnum(DisplayLocations) + display?: DisplayLocations; +} diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts new file mode 100644 index 0000000..3f3c1f5 --- /dev/null +++ b/src/modules/media/media.controller.ts @@ -0,0 +1,48 @@ +import { inject } from "inversify"; +import { controller, httpGet, requestParam } from "inversify-express-utils"; + +import { MediaService } from "./media.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/medias") +@ApiTags("Media") +export class MediaController extends BaseController { + @inject(IOCTYPES.MediaService) mediaService: MediaService; + + @ApiOperation("get all active sliders") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/sliders") + async getSliders() { + const data = await this.mediaService.getSliders(); + return this.response({ sliders: data }); + } + + @ApiOperation("get all active banners") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/banners") + async getBanners() { + const data = await this.mediaService.getBanners(); + return this.response({ banners: data }); + } + + @ApiOperation("Fetch a single slider by ID") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("sliderId", "id of slider", true) + @httpGet("/sliders/:sliderId") + public async getSliderById(@requestParam("sliderId") sliderId: string) { + const data = await this.mediaService.getSliderById(sliderId); + return this.response(data); + } + + @ApiOperation("Fetch a single banner by ID") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("bannerId", "id of banner", true) + @httpGet("/banners/:bannerId") + public async getBannerById(@requestParam("bannerId") bannerId: string) { + const data = await this.mediaService.getBannerById(bannerId); + return this.response(data); + } +} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts new file mode 100644 index 0000000..efc074b --- /dev/null +++ b/src/modules/media/media.service.ts @@ -0,0 +1,119 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; + +import { DisplayLocations } from "../../common/enums/media.enum"; +import { CommonMessage, MediaMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { CreateBannerDTO, CreateSliderDTO, UpdateBannerDTO, UpdateSliderDTO } from "../admin/DTO/media.dto"; +import { BannerRepository, SliderRepository } from "../admin/repository/media"; + +@injectable() +export class MediaService { + @inject(IOCTYPES.BannerRepository) bannerRepo: BannerRepository; + @inject(IOCTYPES.SliderRepository) sliderRepo: SliderRepository; + + async getSliders() { + return this.sliderRepo.model.find({ isActive: true }); + } + + async getSliderForMobile() { + return this.sliderRepo.model.find({ isActive: true, displayLocation: DisplayLocations.Mobile }); + } + + async getSliderForDesktop() { + return this.sliderRepo.model.find({ isActive: true, displayLocation: DisplayLocations.Desktop }); + } + + async getBanners() { + return this.bannerRepo.model.find({ isActive: true }); + } + + async getAllBannersForAdmin() { + return { banners: await this.bannerRepo.findAll() }; + } + async getAllSlidersForAdmin() { + return { sliders: await this.sliderRepo.findAll() }; + } + + async createSlider(createSliderDto: CreateSliderDTO) { + const newSlider = await this.sliderRepo.model.create({ + ...createSliderDto, + }); + return { + message: MediaMessage.SliderCreated, + newSlider, + }; + } + + async createBanner(createBannerDto: CreateBannerDTO) { + const newBanner = await this.bannerRepo.model.create({ + ...createBannerDto, + }); + return { + message: MediaMessage.BannerCreated, + newBanner, + }; + } + + async updateSlider(sliderId: string, updateSliderDto: UpdateSliderDTO) { + await this.validateMedia(sliderId); + const updatedSlider = await this.sliderRepo.model.findByIdAndUpdate(sliderId, updateSliderDto, { new: true }); + return { + message: CommonMessage.Updated, + updatedSlider, + }; + } + + async updateBanner(bannerId: string, updateBannerDto: UpdateBannerDTO) { + await this.validateMedia(bannerId); + const updatedBanner = await this.bannerRepo.model.findByIdAndUpdate(bannerId, updateBannerDto, { new: true }); + return { + message: CommonMessage.Updated, + updatedBanner, + }; + } + + async deleteMedia(mediaId: string, type: "banner" | "slider") { + await this.validateMedia(mediaId); + if (type === "banner") await this.bannerRepo.delete(mediaId); + else if (type === "slider") await this.sliderRepo.delete(mediaId); + return { + message: CommonMessage.Deleted, + }; + } + + async activateMediaS(MediaId: string, isActive: boolean, type: "banner" | "slider") { + await this.validateMedia(MediaId); + if (type === "banner") await this.bannerRepo.model.findByIdAndUpdate(MediaId, { isActive }, { new: true }); + else if (type === "slider") await this.sliderRepo.model.findByIdAndUpdate(MediaId, { isActive }, { new: true }); + return { + message: MediaMessage.Activated, + }; + } + + async getSliderById(sliderId: string) { + await this.validateMedia(sliderId); + return { + slider: await this.sliderRepo.findById(sliderId), + }; + } + + async getBannerById(bannerId: string) { + await this.validateMedia(bannerId); + return { + slider: await this.bannerRepo.findById(bannerId), + }; + } + + //=======================================> + //=======================================> + //helper method + + async validateMedia(mediaId: string) { + if (!isValidObjectId(mediaId)) throw new BadRequestError(CommonMessage.NotValidId); + const media = (await this.sliderRepo.findById(mediaId)) || (await this.bannerRepo.findById(mediaId)); + if (!media) throw new BadRequestError(CommonMessage.NotValidId); + return media; + } +} diff --git a/src/modules/newsletter/DTO/subscribe.dto.ts b/src/modules/newsletter/DTO/subscribe.dto.ts new file mode 100644 index 0000000..cc4fddb --- /dev/null +++ b/src/modules/newsletter/DTO/subscribe.dto.ts @@ -0,0 +1,13 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class SubscribeRequestDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsEmail() + @ApiProperty({ type: "string", description: "email of client", example: "hamid@gmail.com" }) + email: string; +} diff --git a/src/modules/newsletter/model/Abstractions/INewsletter.ts b/src/modules/newsletter/model/Abstractions/INewsletter.ts new file mode 100644 index 0000000..030db1f --- /dev/null +++ b/src/modules/newsletter/model/Abstractions/INewsletter.ts @@ -0,0 +1,3 @@ +export interface INewsletter { + email: string; +} diff --git a/src/modules/newsletter/model/newsletter.model.ts b/src/modules/newsletter/model/newsletter.model.ts new file mode 100644 index 0000000..e4bba40 --- /dev/null +++ b/src/modules/newsletter/model/newsletter.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { INewsletter } from "./Abstractions/INewsletter"; + +const NewsletterSchema = new Schema( + { + email: { type: String, required: true }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); +const NewsletterModel = model("Newsletter", NewsletterSchema); + +export { NewsletterModel }; diff --git a/src/modules/newsletter/newsletter.controller.ts b/src/modules/newsletter/newsletter.controller.ts new file mode 100644 index 0000000..51cc5be --- /dev/null +++ b/src/modules/newsletter/newsletter.controller.ts @@ -0,0 +1,27 @@ +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpPost, requestBody } from "inversify-express-utils"; + +import { SubscribeRequestDTO } from "./DTO/subscribe.dto"; +import { NewsletterService } from "./newsletter.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/newsletter") +@ApiTags("Newsletter") +export class NewsletterController extends BaseController { + @inject(IOCTYPES.NewsletterService) private newsletterService: NewsletterService; + + @ApiOperation("subscribe to newsletter and promotion") + @ApiResponse("created successfully", HttpStatus.Ok) + @ApiModel(SubscribeRequestDTO) + @httpPost("/subscribe", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(SubscribeRequestDTO)) + public async createSubscribeRequest(@requestBody() createDto: SubscribeRequestDTO) { + const data = await this.newsletterService.createSubscribeRequest(createDto); + return this.response(data); + } +} diff --git a/src/modules/newsletter/newsletter.repository.ts b/src/modules/newsletter/newsletter.repository.ts new file mode 100644 index 0000000..d6285bc --- /dev/null +++ b/src/modules/newsletter/newsletter.repository.ts @@ -0,0 +1,13 @@ +import { INewsletter } from "./model/Abstractions/INewsletter"; +import { NewsletterModel } from "./model/newsletter.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class NewsletterRepo extends BaseRepository { + constructor() { + super(NewsletterModel); + } +} + +export function CreateNewsletterRepo(): NewsletterRepo { + return new NewsletterRepo(); +} diff --git a/src/modules/newsletter/newsletter.service.ts b/src/modules/newsletter/newsletter.service.ts new file mode 100644 index 0000000..1c6442e --- /dev/null +++ b/src/modules/newsletter/newsletter.service.ts @@ -0,0 +1,24 @@ +import { inject, injectable } from "inversify"; + +import { SubscribeRequestDTO } from "./DTO/subscribe.dto"; +import { NewsletterRepo } from "./newsletter.repository"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class NewsletterService { + @inject(IOCTYPES.NewsletterRepo) private newsletterRepo: NewsletterRepo; + + async createSubscribeRequest(createDto: SubscribeRequestDTO) { + await this.newsletterRepo.model.findOneAndUpdate( + { email: createDto.email }, + { + ...createDto, + }, + { upsert: true }, + ); + return { + message: CommonMessage.Created, + }; + } +} diff --git a/src/modules/notification/DTO/notification-create.dto.ts b/src/modules/notification/DTO/notification-create.dto.ts new file mode 100644 index 0000000..45e9c12 --- /dev/null +++ b/src/modules/notification/DTO/notification-create.dto.ts @@ -0,0 +1,58 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateNotificationForAllSellerDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of Notifcation", example: "نوتیف به همه فروشندگان" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "message of Notification", example: "متن پیام" }) + message: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "type of Notification", + example: "announcement | order | product | shipment | fine | wallet | ticket | chat | contract", + }) + type: string; +} + +export class CreateNotificationForSpecificDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "title of Notifcation", example: "نوتیف به فروشنده خاص" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "message of Notification", example: "متن پیام" }) + message: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "type of Notification", + example: "announcement | order | product | shipment | fine | wallet | ticket | chat | contract", + }) + type: string; + + @Expose() + @IsNotEmpty() + @IsString({ each: true }) + @ApiProperty({ type: "array", description: "ids of Sellers", example: ["60f6f7d2a5f7e7001f7e2d6e", "60f6f7d2a5f7e7001f7e2d6f"] }) + sellerIds: string[]; +} diff --git a/src/modules/notification/DTO/sellerNotificationQuery.dto.ts b/src/modules/notification/DTO/sellerNotificationQuery.dto.ts new file mode 100644 index 0000000..756f5bd --- /dev/null +++ b/src/modules/notification/DTO/sellerNotificationQuery.dto.ts @@ -0,0 +1,29 @@ +import { Expose, Transform } from "class-transformer"; +import { IsOptional } from "class-validator"; + +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { NotificationType } from "../models/Abstraction/INotification"; + +export class SellerNotificationQueryDTO extends PaginationDTO { + @Expose() + @IsOptional() + @Transform(({ value, obj }) => { + if (value === undefined) { + delete obj.unread; + } + return value && value === "true"; + }) + unread?: string; + + @Expose() + @IsOptional() + type?: NotificationType; + + @Expose() + @IsOptional() + since?: number; + + @Expose() + @IsOptional() + q?: string; +} diff --git a/src/modules/notification/constants/index.ts b/src/modules/notification/constants/index.ts new file mode 100644 index 0000000..e56e630 --- /dev/null +++ b/src/modules/notification/constants/index.ts @@ -0,0 +1,50 @@ +export const NotificationTitle = Object.freeze({ + NEW_ORDER: "سفارش جدید", + NEW_PRODUCT: "کالای جدید", + PRODUCT_APPROVED: "تایید کالا", + ANNOUNCE: "اطلاعیه", + FINE: "جریمه", + DEBIT: "بدهی", + CREDIT: "بستانکاری", + WITHDRAWAL_REQUEST: "درخواست برداشت", + WITHDRAWAL_COMPLETED: "برداشت انجام شد", + WITHDRAWAL_FAILED: "برداشت رد شد", + PRODUCT_REJECTED: "رد کالا", + ACCOUNT_SUSPENDED: "تعلیق حساب", + ACCOUNT_REACTIVATED: "فعال‌سازی مجدد حساب", + SHIPMENT_STATUS_UPDATE: "بروزرسانی وضعیت ارسال", + TICKET_CREATED: "تیکت جدیدی ثبت شده", + NEW_CHAT: "چت جدید", + APPROVE_CONTRACT: "تایید قرارداد", + APPROVE_WHOLESALE: "تایید عمده‌فروشی", + PRODUCT_COMMENT: "نظر جدید", + PRODUCT_QUESTION: "سوال جدید", + REJECT_DOCUMENT: "رد مدارک", + ORDER_RETURNED: "برگشت سفارش", + DEACTIVATED: "غیر فعال شدن پنل", +}); + +export const NotificationMessage = Object.freeze({ + NEW_ORDER: "سفارش جدید دریافت شد.شناسه سفارش:", + NEW_PRODUCT: "کالا جدیدی توسط ادمین برای شما ثبت شد.نام کالا:", + PRODUCT_APPROVED: "کالا ثبت شده توسط مدیریت تایید شد.نام کالا:", + ANNOUNCE: "اطلاعیه جدید دریافت شد", + FINE: "جریمه‌ای برای حساب شما ثبت شد", + DEBIT: "بدهی به حساب شما افزوده شد", + CREDIT: "بستانکاری به حساب شما افزوده شد", + WITHDRAWAL_REQUEST: "درخواست برداشت شما ثبت شد", + WITHDRAWAL_COMPLETED: "برداشت شما با موفقیت انجام شد", + TICKET_CREATED: "تیکت جدیدی ثبت شد", + WITHDRAWAL_FAILED: "برداشت شما رد شد", + PRODUCT_REJECTED: "کالای ثبت شده شما رد شد", + ACCOUNT_SUSPENDED: "حساب شما تعلیق شد", + ACCOUNT_REACTIVATED: "حساب شما فعال‌سازی شد", + SHIPMENT_STATUS_UPDATE: "وضعیت ارسال کالا بروزرسانی شد", + NEW_CHAT: "چت جدید دریافت شد", + APPROVE_CONTRACT: "قرارداد شما تایید شد", + APPROVE_WHOLESALE: "درخواست عمده‌فروشی شما تایید شد", + PRODUCT_COMMENT: "نظر جدیدی برای کالا ثبت شد", + PRODUCT_QUESTION: "سوال جدیدی برای کالا ثبت شد", + ORDER_RETURNED: "سفارش شما توسط خریدار مرجوع شد. شناسه سفارش: [order_number]. لینک تصاویر مرجوعی: [return_images_link]", + DEACTIVATED: "پنل شما غیر فعال شده است. لطفا با پشتیبانی تماس بگیرید", +}); diff --git a/src/modules/notification/models/Abstraction/INotification.ts b/src/modules/notification/models/Abstraction/INotification.ts new file mode 100644 index 0000000..5b65542 --- /dev/null +++ b/src/modules/notification/models/Abstraction/INotification.ts @@ -0,0 +1,26 @@ +import { Types } from "mongoose"; + +export enum NotificationType { + ORDER = "order", + PRODUCT = "product", + SHIPMENT = "shipment", + FINE = "fine", + ANNOUNCEMENT = "announcement", + WALLET = "wallet", + TICKET = "ticket", + CHAT = "chat", + CONTRACT = "contract", + DEACTIVATED = "deactivated", + REJECT_DOCUMENT = "REJECT_DOCUMENT", +} + +export interface INotification { + recipient: string; + seller: Types.ObjectId; + admin: Types.ObjectId; + type: NotificationType; + title: string; + message: string; + meta: string; + read: boolean; +} diff --git a/src/modules/notification/models/notification.model.ts b/src/modules/notification/models/notification.model.ts new file mode 100644 index 0000000..573e885 --- /dev/null +++ b/src/modules/notification/models/notification.model.ts @@ -0,0 +1,21 @@ +import { Schema, model } from "mongoose"; + +import { INotification, NotificationType } from "./Abstraction/INotification"; + +const NotificationSchema = new Schema( + { + recipient: { type: String, enum: ["seller", "admin", "allSeller"], required: true }, + seller: { type: Schema.Types.ObjectId, ref: "Seller", index: true }, + admin: { type: Schema.Types.ObjectId, ref: "Admin", index: true }, + type: { type: String, enum: NotificationType, required: true }, + title: { type: String, required: true }, + message: { type: String, required: true }, + meta: { type: String, default: null }, + read: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const NotificationModel = model("Notification", NotificationSchema); + +export { NotificationModel }; diff --git a/src/modules/notification/notification.controller.ts b/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..7e5dc09 --- /dev/null +++ b/src/modules/notification/notification.controller.ts @@ -0,0 +1,52 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestParam } from "inversify-express-utils"; + +import { NotificationService } from "./notification.service"; +import { HttpStatus } from "../../common"; +import { SellerNotificationQueryDTO } from "./DTO/sellerNotificationQuery.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { ParamDto } from "../../common/dto/param.dto"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; + +@controller("/notification") +@ApiTags("Notification") +class NotificationController extends BaseController { + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + + @ApiOperation("get all seller notification") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("q", "search query") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("unread", "specify the read status ==> value = true") + @ApiQuery("type", "specify the type of notification ==> value = order | fine | wallet | announcement | product | shipment") + @ApiQuery( + "since", + "time of notification created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiAuth() + @httpGet("/seller", Guard.authSeller(), ValidationMiddleware.validateQuery(SellerNotificationQueryDTO)) + public async getAllNotification(@request() req: Request, @queryParam() queryDto: SellerNotificationQueryDTO) { + const seller = req.user as ISeller; + const { count, notifications } = await this.notificationService.getSellerNotifications(seller._id.toString(), queryDto); + const { pager } = this.paginate(count); + return this.response({ pager, notifications }); + } + + @ApiOperation("mark seller notification as read") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "notification id", true) + @ApiAuth() + @httpPost("/:id/read", Guard.authSeller(), ValidationMiddleware.validateParameter(ParamDto)) + public async markAsRead(@requestParam() paramDto: ParamDto) { + const data = await this.notificationService.markAsRead(paramDto.id); + return this.response(data); + } +} + +export { NotificationController }; diff --git a/src/modules/notification/notification.repository.ts b/src/modules/notification/notification.repository.ts new file mode 100644 index 0000000..be9cef4 --- /dev/null +++ b/src/modules/notification/notification.repository.ts @@ -0,0 +1,92 @@ +import { FilterQuery } from "mongoose"; + +import { SellerNotificationQueryDTO } from "./DTO/sellerNotificationQuery.dto"; +import { INotification } from "./models/Abstraction/INotification"; +import { NotificationModel } from "./models/notification.model"; +import { BaseRepository } from "../../common/base/repository"; +import { NotificationQuery } from "../../common/types/query.type"; +import { paginationUtils } from "../../utils/pagination.utils"; + +class NotificationRepo extends BaseRepository { + constructor() { + super(NotificationModel); + } + + async getSellerNotifications(sellerId: string, queries: SellerNotificationQueryDTO) { + const { skip, limit } = paginationUtils(queries); + + //TODO: Refactor this to use the query builder + const query: FilterQuery = { + $or: [{ seller: sellerId }, { recipient: "allSeller" }], + }; + + if (queries.unread) query.read = false; + if (queries.unread !== undefined && !!queries.unread === false) query.read = true; + + if (queries.since) query.createdAt = { $gte: new Date(queries.since) }; + if (queries.type) query.type = queries.type; + + if (queries.q) { + query.$or = [{ title: { $regex: queries.q, $options: "i" } }, { message: { $regex: queries.q, $options: "i" } }]; + } + + const count = await this.model.countDocuments(query); + + const docs = await this.model.find(query).skip(skip).limit(limit).sort({ createdAt: -1 }); + + return { + docs, + count, + }; + } + + async getAdminNotifications(queries: NotificationQuery) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const query: { [k: string]: unknown } = { + recipient: "admin", + }; + if (queries.unread) query.read = false; + if (queries.since) query.createdAt = { $gte: new Date(queries.since) }; + if (queries.type) query.type = queries.type; + + const count = await this.model.countDocuments(query); + + const docs = await this.model.find(query).skip(skip).limit(limit).sort({ createdAt: -1 }); + + return { + docs, + count, + }; + } + + async getAllSellerNotificationsForAdmin(queries: NotificationQuery) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const query: { [k: string]: unknown } = { + recipient: "allSeller", + }; + if (queries.unread) query.read = false; + if (queries.since) query.createdAt = { $gte: new Date(queries.since) }; + if (queries.type) query.type = queries.type; + + const count = await this.model.countDocuments(query); + + const docs = await this.model.find(query).skip(skip).limit(limit).sort({ createdAt: -1 }); + + return { + docs, + count, + }; + } +} + +function createNotificationRepo(): NotificationRepo { + return new NotificationRepo(); +} + +export { createNotificationRepo, NotificationRepo }; diff --git a/src/modules/notification/notification.service.ts b/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..bfe6066 --- /dev/null +++ b/src/modules/notification/notification.service.ts @@ -0,0 +1,276 @@ +import { inject, injectable } from "inversify"; +import { ClientSession } from "mongoose"; + +import { NotificationMessage, NotificationTitle } from "./constants"; +import { CreateNotificationForAllSellerDTO, CreateNotificationForSpecificDTO } from "./DTO/notification-create.dto"; +import { NotificationType } from "./models/Abstraction/INotification"; +import { NotificationRepo } from "./notification.repository"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { NotificationQuery } from "../../common/types/query.type"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { SMS } from "../../utils/sms.service"; +import { SellerRepository } from "../seller/seller.repository"; +import { SellerNotificationQueryDTO } from "./DTO/sellerNotificationQuery.dto"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; + +export interface INotificationDate { + recipient: "seller" | "admin" | "allSeller"; + type: NotificationType; + title: string; + message: string; + seller?: string; + admin?: string; +} + +@injectable() +class NotificationService { + @inject(IOCTYPES.NotificationRepo) notificationRepo: NotificationRepo; + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + + async createNotification( + recipient: "seller" | "admin" | "allSeller", + recipientId: string | null, + type: NotificationType, + title: string, + message: string, + session?: ClientSession, + ) { + const notificationData: INotificationDate = { + recipient, + type, + title, + message, + }; + + if (recipient === "seller" && recipientId) { + notificationData.seller = recipientId; + } else if (recipient === "admin" && recipientId) { + notificationData.admin = recipientId; + } else if (recipient === "allSeller") { + notificationData.recipient = "allSeller"; + } + + const notification = await this.notificationRepo.model.create([notificationData], { session }); + return notification[0]; + } + //################## + async markAsRead(notificationId: string) { + // if (!isValidObjectId(notificationId)) throw new BadRequestError(CommonMessage.NotValidId); + + const notification = await this.notificationRepo.model.findByIdAndUpdate(notificationId, { read: true }, { new: true }); + if (!notification) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Updated, + }; + } + //################## + + async getSellerNotifications(sellerId: string, queries: SellerNotificationQueryDTO) { + const { docs, count } = await this.notificationRepo.getSellerNotifications(sellerId, queries); + return { notifications: docs, count }; + } + //################## + + async getAdminNotifications(queries: NotificationQuery) { + const { docs, count } = await this.notificationRepo.getAdminNotifications(queries); + return { notifications: docs, count }; + } + + async getUnreadNotificationCount(sellerId: string) { + return await this.notificationRepo.model.countDocuments({ seller: sellerId, read: false }); + } + //################## + + async notifyNewOrder(recipientId: string, orderId: number, amount: number, session: ClientSession) { + const shop = await this.shopRepo.model.findOne({ owner: recipientId }).session(session); + if (shop?.ownerRef == OwnerRef.ADMIN) { + const title = NotificationTitle.NEW_ORDER; + const message = `${NotificationMessage.NEW_ORDER} ${orderId}`; + return await this.createNotification("admin", null, NotificationType.ORDER, title, message, session); + } + const seller = await this.sellerRepo.model.findById(recipientId).session(session); + const title = NotificationTitle.NEW_ORDER; + const message = `${NotificationMessage.NEW_ORDER} ${orderId}`; + await SMS.sendSellerOrderCreatedSms(seller?.phoneNumber as string, amount, orderId); + return await this.createNotification("seller", seller?._id.toString() as string, NotificationType.ORDER, title, message, session); + } + //################## + + async notifyProductComment(productName: string, session: ClientSession) { + const title = NotificationTitle.PRODUCT_COMMENT; + const message = `${NotificationMessage.PRODUCT_COMMENT} ${productName}`; + return await this.createNotification("admin", null, NotificationType.PRODUCT, title, message, session); + } + //################## + + async notifyToAllSeller(createDto: CreateNotificationForAllSellerDTO) { + const notifications = await this.createNotification( + "allSeller", + null, + createDto.type as NotificationType, + createDto.title, + createDto.message, + ); + return { notifications }; + } + + //################## + async notifyToSpecificSellers(createDto: CreateNotificationForSpecificDTO) { + const bulkOps = createDto.sellerIds.map((sellerId) => ({ + insertOne: { + document: { + recipient: "seller", + seller: sellerId, + type: createDto.type as NotificationType, + title: createDto.title, + message: createDto.message, + }, + }, + })); + const notifications = await this.notificationRepo.model.bulkWrite(bulkOps); + console.log({ notifications }); + // const notifications = await this.createNotification( + // "seller", + // createDto.sellerIds.join(","), + // createDto.type as NotificationType, + // createDto.title, + // createDto.message, + // ); + return { notifications }; + } + //################## + + //################## + + async getNotifyAllSellerForAdmin(queries: NotificationQuery) { + const { docs, count } = await this.notificationRepo.getAllSellerNotificationsForAdmin(queries); + return { notifications: docs, count }; + } + //################## + + async notifyProductQuestion(productName: string, session: ClientSession) { + const title = NotificationTitle.PRODUCT_QUESTION; + const message = `${NotificationMessage.PRODUCT_QUESTION} ${productName}`; + return await this.createNotification("admin", null, NotificationType.PRODUCT, title, message, session); + } + //################## + + async notifyRejectDocument(recipientId: string, rejectReason: string) { + const title = NotificationTitle.REJECT_DOCUMENT; + const message = `${rejectReason}`; + return await this.createNotification("seller", recipientId, NotificationType.REJECT_DOCUMENT, title, message); + } + + //################## + + async notifyNewChat(recipientId: string, recipient: "seller" | "admin", session: ClientSession) { + const title = NotificationTitle.NEW_CHAT; + const message = `${NotificationMessage.NEW_CHAT}`; + return await this.createNotification(recipient, recipientId, NotificationType.CHAT, title, message, session); + } + + // Notify when a product is approved + async notifyProductApproved(recipientId: string, productModel: string, session: ClientSession) { + const title = NotificationTitle.PRODUCT_APPROVED; + const message = `${NotificationMessage.PRODUCT_APPROVED} ${productModel}`; + await this.createNotification("seller", recipientId, NotificationType.PRODUCT, title, message, session); + } + + // Notify when a product is created for shop + async notifyNewProduct(recipientId: string, productName: string) { + const title = NotificationTitle.NEW_PRODUCT; + const message = `${NotificationMessage.NEW_PRODUCT} ${productName}`; + await this.createNotification("seller", recipientId, NotificationType.PRODUCT, title, message); + } + + // Notify when a wallet transaction (credit) occurs + async notifyWalletCredit(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.CREDIT; + const message = `${NotificationMessage.CREDIT}. مبلغ ${amount} تومان به حساب شما افزوده شد.`; + await this.createNotification("seller", recipientId, NotificationType.WALLET, title, message, session); + } + + // Notify when a wallet transaction (debit) occurs + async notifyWalletDebit(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.DEBIT; + const message = `${NotificationMessage.DEBIT}. مبلغ ${amount} تومان از حساب شما کسر شد.`; + await this.createNotification("seller", recipientId, NotificationType.WALLET, title, message, session); + } + + // Notify when a fine is applied + async notifyFine(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.FINE; + const message = `${NotificationMessage.FINE}. مبلغ ${amount} تومان به عنوان جریمه به حساب شما ثبت شد.`; + await this.createNotification("seller", recipientId, NotificationType.FINE, title, message, session); + } + + // Notify when a seller deactived + async notifyDeactive(recipientId: string, session: ClientSession) { + const title = NotificationTitle.DEACTIVATED; + const message = `${NotificationMessage.DEACTIVATED}`; + await this.createNotification("seller", recipientId, NotificationType.DEACTIVATED, title, message, session); + } + + // Notify when a withdrawal request is created + async notifyWithdrawalRequest(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.WITHDRAWAL_REQUEST; + const message = `${NotificationMessage.WITHDRAWAL_REQUEST}. مبلغ ${amount} تومان درخواست برداشت شما ثبت شد.`; + await this.createNotification("seller", recipientId, NotificationType.WALLET, title, message, session); + } + + // Notify when a withdrawal is completed + async notifyWithdrawalCompleted(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.WITHDRAWAL_COMPLETED; + const message = `${NotificationMessage.WITHDRAWAL_COMPLETED}. مبلغ ${amount} تومان به حساب شما واریز شد.`; + return await this.createNotification("seller", recipientId, NotificationType.WALLET, title, message, session); + } + + // Notify when a ticket is created + async notifyCreateTicket(session: ClientSession) { + const title = NotificationTitle.TICKET_CREATED; + const message = `${NotificationMessage.TICKET_CREATED}`; + return await this.createNotification("admin", null, NotificationType.TICKET, title, message, session); + } + + // Notify when a withdrawal is Failed + async notifyWithdrawalFailed(recipientId: string, amount: number, session: ClientSession) { + const title = NotificationTitle.WITHDRAWAL_FAILED; + const message = `${NotificationMessage.WITHDRAWAL_FAILED}. مبلغ ${amount} .`; + await this.createNotification("seller", recipientId, NotificationType.WALLET, title, message, session); + } + + async notifyOrderReturned(recipientId: string, orderId: number, session: ClientSession, returnImagesLink?: string) { + const title = NotificationTitle.ORDER_RETURNED; + const message = NotificationMessage.ORDER_RETURNED.replace("[order_number]", orderId.toString()).replace( + "[return_images_link]", + returnImagesLink as string, + ); + return await this.createNotification("seller", recipientId, NotificationType.ORDER, title, message, session); + } + + // Notify when a new announcement is made + async notifyAnnouncement(recipientId: string, announcementText: string, session: ClientSession) { + const title = NotificationTitle.ANNOUNCE; + const message = `${NotificationMessage.ANNOUNCE}: ${announcementText}`; + await this.createNotification("seller", recipientId, NotificationType.ANNOUNCEMENT, title, message, session); + } + + // Notify when a contract of seller approved + async notifyApproveContract(recipientId: string, session: ClientSession) { + const title = NotificationTitle.APPROVE_CONTRACT; + const message = `${NotificationMessage.APPROVE_CONTRACT}`; + await this.createNotification("seller", recipientId, NotificationType.CONTRACT, title, message, session); + } + + // Notify when a wholesale request of seller approved + async notifyWholesaleRequestApproved(recipientId: string, session: ClientSession) { + const title = NotificationTitle.APPROVE_WHOLESALE; + const message = `${NotificationMessage.APPROVE_WHOLESALE}`; + await this.createNotification("seller", recipientId, NotificationType.ANNOUNCEMENT, title, message, session); + } +} + +export { NotificationService }; diff --git a/src/modules/order/DTO/insertTrackCode.dto.ts b/src/modules/order/DTO/insertTrackCode.dto.ts new file mode 100644 index 0000000..f798708 --- /dev/null +++ b/src/modules/order/DTO/insertTrackCode.dto.ts @@ -0,0 +1,66 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId, IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { OrderModel } from "../models/order.model"; + +export class InsertTrackCodeDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the track code of order", example: "1234d3656456" }) + trackCode: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "the posting date of order", example: "1403/08/09" }) + postingDate: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the url of postingReceipt of order", example: "https://images.com" }) + postingReceipt?: string; +} + +export class TrackCodesDTO extends InsertTrackCodeDTO { + @Expose() + @IsNotEmpty() + @IsInt() + @IsValidId(OrderModel) + @ApiProperty({ type: "string", description: "the order id", example: 10102 }) + orderId: number; +} + +export class InsertTrackCodeGroupDTO { + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => TrackCodesDTO) + @ApiProperty({ + type: "Array", + description: "the track codes of order", + example: [ + { + trackCode: "1234d3656456", + postingDate: "1403/08/09", + postingReceipt: "https://images.com ==> it is optional", + orderId: 10102, + }, + { + trackCode: "1234d365356", + postingDate: "1403/08/09", + postingReceipt: "https://images.com ==> it is optional", + orderId: 10104, + }, + ], + }) + trackCodes: TrackCodesDTO[]; +} diff --git a/src/modules/order/DTO/order.dto.ts b/src/modules/order/DTO/order.dto.ts new file mode 100644 index 0000000..596abf1 --- /dev/null +++ b/src/modules/order/DTO/order.dto.ts @@ -0,0 +1,84 @@ +import { Expose, Type } from "class-transformer"; + +import { ProductDTO, ProductDetailVariantDTO } from "../../product/DTO/product.dto"; +import { ShipmentMethodDTO } from "../../shipment/DTO/shipment.dto"; +import { ShopDTO } from "../../shop/DTO/shop.dto"; + +export class ShipmentItemsDTO { + @Expose() + _id: string; + + @Expose() + product: ProductDTO; + + @Expose() + variant: ProductDetailVariantDTO; + + @Expose() + quantity: number; + + @Expose() + cancelled_quantity: number; + + @Expose() + returned_quantity: number; + + @Expose() + selling_price: number; + + @Expose() + retail_price: number; + + @Expose() + discount_percent: number; + + @Expose() + shipmentCost: number; +} + +export class OrderItemDTO { + @Expose() + _id: string; + + @Expose() + order_id: number; + + @Expose() + @Type(() => ShopDTO) + shop: ShopDTO; + + @Expose() + @Type(() => ShipmentItemsDTO) + shipmentItems: ShipmentItemsDTO[]; + + @Expose() + @Type(() => ShipmentMethodDTO) + shipper: ShipmentMethodDTO; + + @Expose() + trackCode: string; + + @Expose() + itemsCount: number; + + @Expose() + totalPaymentPrice: number; + + @Expose() + totalRetailPrice: number; + + @Expose() + totalSellingPrice: number; + + @Expose() + totalShipmentCost: number; + + @Expose() + postingDate: string; + + @Expose() + postingReceipt: string; + + @Expose() + status: string; +} diff --git a/src/modules/order/DTO/received.dto.ts b/src/modules/order/DTO/received.dto.ts new file mode 100644 index 0000000..36d4c95 --- /dev/null +++ b/src/modules/order/DTO/received.dto.ts @@ -0,0 +1,21 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { OrderItemModel } from "../models/orderItem.model"; + +export class ReceivedOrderItemDTO { + @Expose() + @IsNotEmpty() + @IsValidId(OrderItemModel) + @IsString() + @ApiProperty({ type: "string", description: "the id of orderItem ", example: "66f7bf9a71d13496054d7c2a" }) + orderItemId: string; + + // @Expose() + // @IsNotEmpty() + // @IsString() + // @ApiProperty({ type: "string", description: "the id of shipment item ", example: "66f7bf9a71d13496054d7c2a" }) + // shipmentItemId: string; +} diff --git a/src/modules/order/DTO/sellerOrder.dto.ts b/src/modules/order/DTO/sellerOrder.dto.ts new file mode 100644 index 0000000..66b2545 --- /dev/null +++ b/src/modules/order/DTO/sellerOrder.dto.ts @@ -0,0 +1,61 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; + +import { OrderItemDTO } from "./order.dto"; +import { ShipmentAddressDTO } from "./userOrder.dto"; +import { OrdersStatus } from "../../../common/enums/order.enum"; +import { TimeService } from "../../../utils/time.service"; +import { PaymentDTO } from "../../payment/DTO/payment.dto"; +import { MiniUserDTO } from "../../user/DTO/user.dto"; +import { IOrder } from "../models/Abstraction/IOrder"; + +export class SellerOrdersDTO { + @Expose() + _id: number; + + @Expose() + @Type(() => MiniUserDTO) + user: MiniUserDTO; + + @Expose() + @Type(() => PaymentDTO) + payment: PaymentDTO; + + @Expose() + @Type(() => OrderItemDTO) + orderItems: OrderItemDTO; + + @Expose() + @Type(() => ShipmentAddressDTO) + shipmentAddress: ShipmentAddressDTO; + + @Expose() + orderStatus: OrdersStatus; + + @Expose() + totalSellingPrice: number; + + @Expose() + totalRetailPrice: number; + + @Expose() + totalShipmentCost: number; + + @Expose() + totalPaymentPrice: number; + + @Expose() + itemsCount: number; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformOrder(data: IOrder): SellerOrdersDTO { + const OrderDTO = plainToClass(SellerOrdersDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return OrderDTO; + } +} diff --git a/src/modules/order/DTO/userOrder.dto.ts b/src/modules/order/DTO/userOrder.dto.ts new file mode 100644 index 0000000..52ee893 --- /dev/null +++ b/src/modules/order/DTO/userOrder.dto.ts @@ -0,0 +1,65 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; + +import { OrderItemDTO } from "./order.dto"; +import { OrdersStatus } from "../../../common/enums/order.enum"; +import { TimeService } from "../../../utils/time.service"; +import { PaymentDTO } from "../../payment/DTO/payment.dto"; +import { UserDTO } from "../../user/DTO/user.dto"; +import { IOrder } from "../models/Abstraction/IOrder"; + +export class ShipmentAddressDTO { + @Expose() + address: string; + + @Expose() + city: string; + + @Expose() + province: string; + + @Expose() + plaque: string; + + @Expose() + postalCode: string; + + @Expose() + phone: string; +} + +export class UserOrderDTO { + @Expose() + _id: number; + + @Expose() + @Type(() => UserDTO) + user: UserDTO; + + @Expose() + @Type(() => PaymentDTO) + payment: PaymentDTO; + + @Expose() + @Type(() => OrderItemDTO) + orderItems: OrderItemDTO[]; + + @Expose() + @Type(() => ShipmentAddressDTO) + shipmentAddress: ShipmentAddressDTO; + + @Expose() + orderStatus: OrdersStatus; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformOrder(data: IOrder): UserOrderDTO { + const OrderDTO = plainToClass(UserOrderDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return OrderDTO; + } +} diff --git a/src/modules/order/models/Abstraction/IOrder.ts b/src/modules/order/models/Abstraction/IOrder.ts new file mode 100644 index 0000000..d1af703 --- /dev/null +++ b/src/modules/order/models/Abstraction/IOrder.ts @@ -0,0 +1,20 @@ +import { Types } from "mongoose"; + +import { OrdersStatus } from "../../../../common/enums/order.enum"; + +export interface IOrder { + _id: number; + user: Types.ObjectId; + payment: Types.ObjectId; + shipmentAddress: IShipmentAddress; + orderStatus: OrdersStatus; +} + +export interface IShipmentAddress { + address: string; + city: string; + province: string; + plaque: string; + postalCode: string; + phone: string; +} diff --git a/src/modules/order/models/Abstraction/IOrderItem.ts b/src/modules/order/models/Abstraction/IOrderItem.ts new file mode 100644 index 0000000..dbaaff7 --- /dev/null +++ b/src/modules/order/models/Abstraction/IOrderItem.ts @@ -0,0 +1,22 @@ +import { Types } from "mongoose"; + +import { OrderItemsStatus } from "../../../../common/enums/order.enum"; +import { IShipmentItems } from "../../../cart/models/Abstraction/ICartShipmentItem"; + +export interface IOrderItem { + _id?: Types.ObjectId; + order: number; + shop: Types.ObjectId; + shipmentItems: IShipmentItems[]; + shipper: number; + totalSellingPrice: number; + totalRetailPrice: number; + totalShipmentCost: number; + totalCouponDiscount: number; + totalPaymentPrice: number; + itemsCount: number; + trackCode?: string; + postingDate?: string; + postingReceipt?: string; + status: OrderItemsStatus; +} diff --git a/src/modules/order/models/order.model.ts b/src/modules/order/models/order.model.ts new file mode 100644 index 0000000..f62f1ca --- /dev/null +++ b/src/modules/order/models/order.model.ts @@ -0,0 +1,48 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IOrder, IShipmentAddress } from "./Abstraction/IOrder"; +import { OrdersStatus } from "../../../common/enums/order.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const ShipmentAddressSchema = new Schema({ + address: { type: String, required: true }, + city: { type: String, required: true }, + province: { type: String, required: true }, + phone: { type: String, required: true }, + plaque: { type: String, required: true }, + postalCode: { type: String, required: true }, +}); + +const OrderSchema = new Schema( + { + _id: Number, + user: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, + payment: { type: Schema.Types.ObjectId, ref: "CartPayment", required: true, index: true }, + shipmentAddress: { type: ShipmentAddressSchema, required: true }, + orderStatus: { type: String, enum: OrdersStatus, default: OrdersStatus.wait_payment }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, _id: false, id: false }, +); + +OrderSchema.virtual("orderItems", { + ref: "OrderItem", + localField: "_id", + foreignField: "order", + justOne: false, +}); + +OrderSchema.pre(["find", "findOne"], function (next) { + this.populate([ + { path: "user" }, + { path: "payment" }, + { path: "orderItems", populate: { path: "shop shipper shipmentItems.product shipmentItems.variant" } }, + ]); + + next(); +}); + +OrderSchema.plugin(AutoIncrement, { id: "order_id", inc_field: "_id", start_seq: 10100 }); +const OrderModel = model("Order", OrderSchema); +export { OrderModel }; diff --git a/src/modules/order/models/orderItem.model.ts b/src/modules/order/models/orderItem.model.ts new file mode 100644 index 0000000..55ac1d0 --- /dev/null +++ b/src/modules/order/models/orderItem.model.ts @@ -0,0 +1,34 @@ +import { Schema, model } from "mongoose"; + +import { IOrderItem } from "./Abstraction/IOrderItem"; +import { OrderItemsStatus } from "../../../common/enums/order.enum"; +import { OrderQueue } from "../../../queues/order/OrderQueue"; +import { ShipmentItemsSchema } from "../../cart/models/cartShipmentItem.model"; + +const OrderItemSchema = new Schema( + { + order: { type: Number, ref: "Order", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true, index: true }, + shipmentItems: { type: [ShipmentItemsSchema], default: [] }, + shipper: { type: Number, ref: "Shipment", required: true }, + totalSellingPrice: { type: Number, required: true }, + totalRetailPrice: { type: Number, required: true }, + totalShipmentCost: { type: Number, required: true }, + totalCouponDiscount: { type: Number, default: 0 }, + totalPaymentPrice: { type: Number, required: true }, + itemsCount: { type: Number, required: true }, + trackCode: { type: String, default: null }, + postingDate: { type: String, default: null }, + postingReceipt: { type: String, default: null }, + status: { type: String, enum: OrderItemsStatus, default: OrderItemsStatus.Processing }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +OrderItemSchema.post(["save", "findOneAndUpdate"], async function (doc) { + await OrderQueue.addOrderToCheckStatus(doc.order); +}); + +const OrderItemModel = model("OrderItem", OrderItemSchema); + +export { OrderItemModel }; diff --git a/src/modules/order/order.controller.ts b/src/modules/order/order.controller.ts new file mode 100644 index 0000000..42b3afd --- /dev/null +++ b/src/modules/order/order.controller.ts @@ -0,0 +1,164 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody, requestParam } from "inversify-express-utils"; + +import { OrderService } from "./order.service"; +import { HttpStatus } from "../../common"; +import { InsertTrackCodeDTO, InsertTrackCodeGroupDTO } from "./DTO/insertTrackCode.dto"; +import { ReceivedOrderItemDTO } from "./DTO/received.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/order") +@ApiTags("Order") +class OrderController extends BaseController { + @inject(IOCTYPES.OrderService) orderService: OrderService; + + //############################################################### + //############################################################### + + //########################################################### + //########################################################### + //getSingle order details + @ApiOperation("get an order details of seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("orderId", "the user seller id", true) + @ApiAuth() + @httpGet("/:orderId/seller", Guard.authSeller()) + public async getSellerOrderDetail(@requestParam("orderId") orderId: string, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.orderService.getSellerOrderDetail(+orderId, seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get an order details of user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("orderId", "the user order id", true) + @ApiAuth() + @httpGet("/:orderId/user", Guard.authUser()) + public async getUserOrderDetail(@requestParam("orderId") orderId: string, @request() req: Request) { + const user = req.user as IUser; + const data = await this.orderService.getUserOrderDetail(+orderId, user._id.toString()); + return this.response(data); + } + + @ApiOperation("get an order details of user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("orderId", "the user order id", true) + @ApiParam("orderItemId", "the user orderItem id", true) + @ApiAuth() + @httpGet("/:orderId/user/:orderItemId", Guard.authUser()) + public async getUserOrderItemsDetail( + @requestParam("orderId") orderId: string, + @requestParam("orderItemId") orderItemId: string, + @request() req: Request, + ) { + const user = req.user as IUser; + const data = await this.orderService.getUserOrderItemsDetail(+orderId, orderItemId, user._id.toString()); + return this.response(data); + } + + //########################################################### + //########################################################### + //insert track code + + @ApiOperation("insert a track code in an orders of user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(InsertTrackCodeGroupDTO) + @ApiAuth() + @httpPost("/seller/track-code/group", Guard.authSeller(), ValidationMiddleware.validateInput(InsertTrackCodeGroupDTO)) + public async insertTrackCodeGroup(@request() req: Request, @requestBody() insertDto: InsertTrackCodeGroupDTO) { + const seller = req.user as ISeller; + const data = await this.orderService.insertTrackCodeGroup(seller._id.toString(), insertDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("insert a track code in an order of seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(InsertTrackCodeDTO) + @ApiParam("orderId", "the seller order id", true) + @ApiAuth() + @httpPost("/:orderId/seller/track-code", Guard.authSeller(), ValidationMiddleware.validateInput(InsertTrackCodeDTO)) + public async insertTrackCode( + @requestParam("orderId") orderId: string, + @request() req: Request, + @requestBody() insertDto: InsertTrackCodeDTO, + ) { + const seller = req.user as ISeller; + const data = await this.orderService.insertTrackCode(+orderId, seller._id.toString(), insertDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("insert a track code in an order of native shop by admin") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(InsertTrackCodeDTO) + @ApiParam("orderId", "the native shop order id", true) + @ApiAuth() + @httpPost("/:orderId/admin/track-code", Guard.authAdmin(), ValidationMiddleware.validateInput(InsertTrackCodeDTO)) + public async insertNativeShopTrackCode( + @requestParam("orderId") orderId: string, + @request() req: Request, + @requestBody() insertDto: InsertTrackCodeDTO, + ) { + const admin = req.user as IUser; + const data = await this.orderService.insertNativeShopTrackCode(+orderId, admin._id.toString(), insertDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("confirm receiving an order items of seller ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(ReceivedOrderItemDTO) + @ApiParam("orderId", "the user order id", true) + @ApiAuth() + @httpPost("/:orderId/user/received", Guard.authUser(), ValidationMiddleware.validateInput(ReceivedOrderItemDTO)) + public async setOrderItemReceived( + @requestParam("orderId") orderId: string, + @request() req: Request, + @requestBody() receivedOrderItemDTO: ReceivedOrderItemDTO, + ) { + const user = req.user as IUser; + const data = await this.orderService.setOrderItemReceived(+orderId, user._id.toString(), receivedOrderItemDTO); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("send the order status and tracking status to user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("orderId", "the user order id", true) + @ApiAuth() + @httpPost("/:orderId/user/tracking", rateLimit(appConfig.rate), Guard.authUser()) + public async trackOrder(@request() req: Request, @requestParam("orderId") orderId: string) { + const user = req.user as IUser; + const data = await this.orderService.trackOrder(+orderId, user._id.toString(), user.email); + return this.response(data); + } + + //uploader + @ApiOperation("Upload a order post receipt info image ==> need to login as seller") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload", Guard.authSeller(), UploadService.single("image", "shipping-receipt")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { OrderController }; diff --git a/src/modules/order/order.repository.ts b/src/modules/order/order.repository.ts new file mode 100644 index 0000000..522b395 --- /dev/null +++ b/src/modules/order/order.repository.ts @@ -0,0 +1,2107 @@ +import { Types } from "mongoose"; + +import { IOrder } from "./models/Abstraction/IOrder"; +import { IOrderItem } from "./models/Abstraction/IOrderItem"; +import { OrderModel } from "./models/order.model"; +import { OrderItemModel } from "./models/orderItem.model"; +import { BaseRepository } from "../../common/base/repository"; +import { OrderItemsStatus, OrdersStatus } from "../../common/enums/order.enum"; +import { OrderStatusQuery, SellerOrdersQueries } from "../../common/types/query.type"; +import { TimeService } from "../../utils/time.service"; + +class OrderRepository extends BaseRepository { + constructor() { + super(OrderModel); + } + + async getUserOrders(userId: string, statusQuery: OrderStatusQuery) { + const matchQuery: { [k: string]: unknown } = { + user: new Types.ObjectId(userId), + }; + const orderItemsMatchQuery: { [k: string]: unknown } = {}; + + switch (statusQuery) { + case "Cancelled": + matchQuery.orderStatus = OrdersStatus.cancelled_system; + orderItemsMatchQuery["orderItems.status"] = OrderItemsStatus.cancelled_system; + break; + case "Processing": + matchQuery.$or = [{ orderStatus: OrdersStatus.process_by_seller }, { orderStatus: OrdersStatus.wait_payment }]; + orderItemsMatchQuery.$or = [ + { "orderItems.status": OrderItemsStatus.Processing }, + { "orderItems.status": OrderItemsStatus.Shipped }, + ]; + break; + case "Delivered": + matchQuery.orderStatus = OrdersStatus.process_by_seller; + orderItemsMatchQuery["orderItems.status"] = OrderItemsStatus.Delivered; + break; + default: + break; + } + return this.model.aggregate([ + { + $match: matchQuery, + }, + { + $lookup: { + from: "cartpayments", + localField: "payment", + foreignField: "_id", + as: "payment", + }, + }, + { + $unwind: "$payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "payment.payment_method", + foreignField: "_id", + as: "payment.payment_method", + }, + }, + { + $unwind: "$payment.payment_method", + }, + { + $lookup: { + from: "orderitems", + localField: "_id", + foreignField: "order", + as: "orderItems", + }, + }, + { + $unwind: "$orderItems", + }, + { + $match: orderItemsMatchQuery, + }, + { + $lookup: { + from: "shipments", + localField: "orderItems.shipper", + foreignField: "_id", + as: "orderItems.shipper", + }, + }, + { + $unwind: "$orderItems.shipper", + }, + { + $lookup: { + from: "shops", + localField: "orderItems.shop", + foreignField: "_id", + as: "orderItems.shop", + }, + }, + { + $unwind: "$orderItems.shop", + }, + { + $unwind: "$orderItems.shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "orderItems.shipmentItems.product", + foreignField: "_id", + as: "shipmentItemProduct", + }, + }, + { + $unwind: "$shipmentItemProduct", + }, + { + $lookup: { + from: "productvariants", + localField: "orderItems.shipmentItems.variant", + foreignField: "_id", + as: "shipmentItemVariant", + }, + }, + { + $unwind: "$shipmentItemVariant", + }, + { + $group: { + _id: "$orderItems._id", + shop: { $first: "$orderItems.shop" }, + shipper: { $first: "$orderItems.shipper" }, + totalSellingPrice: { $first: "$orderItems.totalSellingPrice" }, + totalRetailPrice: { $first: "$orderItems.totalRetailPrice" }, + totalShipmentCost: { $first: "$orderItems.totalShipmentCost" }, + totalPaymentPrice: { $first: "$orderItems.totalPaymentPrice" }, + itemsCount: { $first: "$orderItems.itemsCount" }, + trackCode: { $first: "$orderItems.trackCode" }, + postingDate: { $first: "$orderItems.postingDate" }, + postingReceipt: { $first: "$orderItems.postingReceipt" }, + status: { $first: "$orderItems.status" }, + shipmentItems: { + $push: { + product: "$shipmentItemProduct", + variant: "$shipmentItemVariant", + quantity: "$orderItems.shipmentItems.quantity", + cancelled_quantity: "$orderItems.shipmentItems.cancelled_quantity", + returned_quantity: "$orderItems.shipmentItems.returned_quantity", + selling_price: "$orderItems.shipmentItems.selling_price", + retail_price: "$orderItems.shipmentItems.retail_price", + discount_percent: "$orderItems.shipmentItems.discount_percent", + shipmentCost: "$orderItems.shipmentItems.shipmentCost", + }, + }, + + orderId: { $first: "$_id" }, + payment: { $first: "$payment" }, + shipmentAddress: { $first: "$shipmentAddress" }, + orderStatus: { $first: "$orderStatus" }, + createdAt: { $first: "$createdAt" }, + }, + }, + { + $group: { + _id: "$orderId", + payment: { $first: "$payment" }, + shipmentAddress: { $first: "$shipmentAddress" }, + orderStatus: { $first: "$orderStatus" }, + createdAt: { $first: "$createdAt" }, + orderItems: { + $push: { + _id: "$_id", // orderItem `_id` + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + shipmentItems: "$shipmentItems", + }, + }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + } + + async getUserOrderDetail(orderId: number, userId: string) { + return this.model.aggregate([ + { + $match: { + _id: orderId, + user: new Types.ObjectId(userId), + }, + }, + { + $lookup: { + from: "cartpayments", + localField: "payment", + foreignField: "_id", + as: "payment", + }, + }, + { + $unwind: "$payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "payment.payment_method", + foreignField: "_id", + as: "payment.payment_method", + }, + }, + { + $unwind: "$payment.payment_method", + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $unwind: "$user", + }, + { + $lookup: { + from: "orderitems", + localField: "_id", + foreignField: "order", + as: "orderItems", + }, + }, + { + $unwind: "$orderItems", + }, + { + $lookup: { + from: "shipments", + localField: "orderItems.shipper", + foreignField: "_id", + as: "orderItems.shipper", + }, + }, + { + $unwind: "$orderItems.shipper", + }, + { + $lookup: { + from: "shops", + localField: "orderItems.shop", + foreignField: "_id", + as: "orderItems.shop", + }, + }, + { + $unwind: "$orderItems.shop", + }, + { + $unwind: "$orderItems.shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "orderItems.shipmentItems.product", + foreignField: "_id", + as: "orderItems.shipmentItems.product", + }, + }, + { + $unwind: "$orderItems.shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "orderItems.shipmentItems.variant", + foreignField: "_id", + as: "orderItems.shipmentItems.variant", + }, + }, + { + $unwind: "$orderItems.shipmentItems.variant", + }, + { + $lookup: { + from: "warranties", + localField: "orderItems.shipmentItems.variant.warranty", + foreignField: "_id", + as: "orderItems.shipmentItems.variant.warranty", + }, + }, + { + $unwind: { + path: "$orderItems.shipmentItems.variant.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "orderItems.shipmentItems.variant.color", + foreignField: "_id", + as: "orderItems.shipmentItems.variant.color", + }, + }, + { + $unwind: { + path: "$orderItems.shipmentItems.variant.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "orderItems.shipmentItems.variant.size", + foreignField: "_id", + as: "orderItems.shipmentItems.variant.size", + }, + }, + { + $unwind: { + path: "$orderItems.shipmentItems.variant.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "orderItems.shipmentItems.variant.meterage", + foreignField: "_id", + as: "orderItems.shipmentItems.variant.meterage", + }, + }, + { + $unwind: { + path: "$orderItems.shipmentItems.variant.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$orderItems._id", + shop: { $first: "$orderItems.shop" }, + shipper: { $first: "$orderItems.shipper" }, + status: { $first: "$orderItems.status" }, + totalSellingPrice: { $first: "$orderItems.totalSellingPrice" }, + totalRetailPrice: { $first: "$orderItems.totalRetailPrice" }, + totalShipmentCost: { $first: "$orderItems.totalShipmentCost" }, + totalPaymentPrice: { $first: "$orderItems.totalPaymentPrice" }, + itemsCount: { $first: "$orderItems.itemsCount" }, + trackCode: { $first: "$orderItems.trackCode" }, + postingDate: { $first: "$orderItems.postingDate" }, + postingReceipt: { $first: "$orderItems.postingReceipt" }, + shipmentItems: { $push: "$orderItems.shipmentItems" }, + orderId: { $first: "$_id" }, + user: { $first: "$user" }, + payment: { $first: "$payment" }, + shipmentAddress: { $first: "$shipmentAddress" }, + orderStatus: { $first: "$orderStatus" }, + createdAt: { $first: "$createdAt" }, + }, + }, + { + $group: { + _id: "$orderId", + user: { $first: "$user" }, + payment: { $first: "$payment" }, + shipmentAddress: { $first: "$shipmentAddress" }, + orderStatus: { $first: "$orderStatus" }, + createdAt: { $first: "$createdAt" }, + orderItems: { + $push: { + _id: "$_id", + order_id: "$orderId", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + shipmentItems: "$shipmentItems", + }, + }, + }, + }, + ]); + } +} + +class OrderItemRepo extends BaseRepository { + constructor() { + super(OrderItemModel); + } + + async getOrderTrackingDetails(orderId: number) { + return this.model.find({ order: orderId, trackCode: { $exists: true } }).populate([{ path: "shipper" }]); + } + + async getUserOrderItemsDetail(orderId: number, orderItemId: string) { + return this.model.aggregate([ + { + $match: { + _id: new Types.ObjectId(orderItemId), + order: orderId, + }, + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "users", + localField: "order.user", + foreignField: "_id", + as: "order.user", + }, + }, + { + $unwind: "$order.user", + }, + { + $unwind: "$shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "shipmentItems.product", + foreignField: "_id", + as: "shipmentItems.product", + }, + }, + { + $unwind: "$shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "shipmentItems.variant", + foreignField: "_id", + as: "shipmentItems.variant", + }, + }, + { + $unwind: "$shipmentItems.variant", + }, + { + $lookup: { + from: "warranties", + localField: "shipmentItems.variant.warranty", + foreignField: "_id", + as: "shipmentItems.variant.warranty", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "shipmentItems.variant.color", + foreignField: "_id", + as: "shipmentItems.variant.color", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "shipmentItems.variant.size", + foreignField: "_id", + as: "shipmentItems.variant.size", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "shipmentItems.variant.meterage", + foreignField: "_id", + as: "shipmentItems.variant.meterage", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shipments", + localField: "shipper", + foreignField: "_id", + as: "shipper", + }, + }, + { + $unwind: "$shipper", + }, + { + $group: { + _id: "$order._id", + user: { $first: "$order.user" }, + // payment: { $first: "$order.payment" }, + shipmentAddress: { $first: "$order.shipmentAddress" }, + orderStatus: { $first: "$order.orderStatus" }, + createdAt: { $first: "$order.createdAt" }, + orderItems: { + $first: { + _id: "$_id", + order_id: "$order._id", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + }, + }, + shipmentItems: { + $push: { + _id: "$shipmentItems._id", + product: "$shipmentItems.product", + variant: "$shipmentItems.variant", + quantity: "$shipmentItems.quantity", + cancelled_quantity: "$shipmentItems.cancelled_quantity", + returned_quantity: "$shipmentItems.returned_quantity", + selling_price: "$shipmentItems.selling_price", + retail_price: "$shipmentItems.retail_price", + discount_percent: "$shipmentItems.discount_percent", + shipmentCost: "$shipmentItems.shipmentCost", + }, + }, + // totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + // totalRetailPrice: { $sum: { $multiply: ["$shipmentItems.retail_price", "$shipmentItems.quantity"] } }, + // totalShipmentCost: { $sum: "$shipmentItems.shipmentCost" }, + // itemsCount: { $sum: "$shipmentItems.quantity" }, + }, + }, + { + $addFields: { + "orderItems.shipmentItems": "$shipmentItems", + // totalPaymentPrice: { $add: ["$totalSellingPrice", "$totalShipmentCost"] }, + }, + }, + ]); + } + + async getOrdersForSellerPanel(shopId: string, queries: SellerOrdersQueries, orderIds?: string[] | string) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const priceQuery: { [k: string]: { [op: string]: number } } = {}; + const orderMatchQuery: any = {}; + + if (queries.minPrice) { + priceQuery.totalPaymentPrice = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + if (priceQuery.totalPaymentPrice) { + priceQuery.totalPaymentPrice.$lte = queries.maxPrice; + } else { + priceQuery.totalPaymentPrice = { $lte: queries.maxPrice }; + } + } + + if (typeof orderIds === "string") { + orderMatchQuery.order = +orderIds; + } else if (orderIds && orderIds.length > 0) { + orderMatchQuery.order = { $in: orderIds.map((id) => +id) }; + } + + const docs = await this.model.aggregate([ + { + $match: { + shop: new Types.ObjectId(shopId), + ...orderMatchQuery, + }, + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "cartpayments", + localField: "order.payment", + foreignField: "_id", + as: "order.payment", + }, + }, + { + $unwind: "$order.payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "order.payment.payment_method", + foreignField: "_id", + as: "order.payment.payment_method", + }, + }, + { + $unwind: "$order.payment.payment_method", + }, + { + $match: { + ...(queries.status && { + "order.payment.paymentStatus": queries.status, + }), + ...(queries.shipperId && { + shipper: +queries.shipperId, + }), + ...(queries.since && { + "order.createdAt": { $gte: new Date(queries.since) }, + }), + }, + }, + { + $lookup: { + from: "users", + localField: "order.user", + foreignField: "_id", + as: "order.user", + }, + }, + { + $unwind: "$order.user", + }, + { + $unwind: "$shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "shipmentItems.product", + foreignField: "_id", + as: "shipmentItems.product", + }, + }, + { + $unwind: "$shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "shipmentItems.variant", + foreignField: "_id", + as: "shipmentItems.variant", + }, + }, + { + $unwind: "$shipmentItems.variant", + }, + { + $lookup: { + from: "shipments", + localField: "shipper", + foreignField: "_id", + as: "shipper", + }, + }, + { + $unwind: "$shipper", + }, + { + $group: { + _id: "$order._id", + user: { $first: "$order.user" }, + payment: { $first: "$order.payment" }, + shipmentAddress: { $first: "$order.shipmentAddress" }, + orderStatus: { $first: "$order.orderStatus" }, + createdAt: { $first: "$order.createdAt" }, + orderItems: { + $first: { + _id: "$_id", + order_id: "$order._id", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + }, + }, + shipmentItems: { + $push: { + _id: "$shipmentItems._id", + product: "$shipmentItems.product", + variant: "$shipmentItems.variant", + quantity: "$shipmentItems.quantity", + cancelled_quantity: "$shipmentItems.cancelled_quantity", + returned_quantity: "$shipmentItems.returned_quantity", + selling_price: "$shipmentItems.selling_price", + retail_price: "$shipmentItems.retail_price", + discount_percent: "$shipmentItems.discount_percent", + shipmentCost: "$shipmentItems.shipmentCost", + }, + }, + // totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + // totalRetailPrice: { $sum: { $multiply: ["$shipmentItems.retail_price", "$shipmentItems.quantity"] } }, + // totalShipmentCost: { $sum: "$shipmentItems.shipmentCost" }, + // itemsCount: { $sum: "$shipmentItems.quantity" }, + }, + }, + + { + $addFields: { + "orderItems.shipmentItems": "$shipmentItems", + // totalPaymentPrice: { $add: ["$totalSellingPrice", "$totalShipmentCost"] }, + }, + }, + { + $match: priceQuery, + }, + { + $sort: { + "order.createdAt": -1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $project: { + shipmentItems: 0, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getOrdersForAdminPanel(queries: SellerOrdersQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const priceQuery: { [k: string]: { [op: string]: number } } = {}; + + if (queries.minPrice) { + priceQuery.totalPaymentPrice = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + if (priceQuery.totalPaymentPrice) { + priceQuery.totalPaymentPrice.$lte = queries.maxPrice; + } else { + priceQuery.totalPaymentPrice = { $lte: queries.maxPrice }; + } + } + + const docs = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "cartpayments", + localField: "order.payment", + foreignField: "_id", + as: "order.payment", + }, + }, + { + $unwind: "$order.payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "order.payment.payment_method", + foreignField: "_id", + as: "order.payment.payment_method", + }, + }, + { + $unwind: "$order.payment.payment_method", + }, + { + $match: { + ...(queries.status && { + "order.payment.paymentStatus": queries.status, + }), + ...(queries.shipperId && { + shipper: +queries.shipperId, + }), + ...(queries.since && { + "order.createdAt": { $gte: new Date(queries.since) }, + }), + }, + }, + { + $lookup: { + from: "users", + localField: "order.user", + foreignField: "_id", + as: "order.user", + }, + }, + { + $unwind: "$order.user", + }, + { + $unwind: "$shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "shipmentItems.product", + foreignField: "_id", + as: "shipmentItems.product", + }, + }, + { + $unwind: "$shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "shipmentItems.variant", + foreignField: "_id", + as: "shipmentItems.variant", + }, + }, + { + $unwind: "$shipmentItems.variant", + }, + { + $lookup: { + from: "shipments", + localField: "shipper", + foreignField: "_id", + as: "shipper", + }, + }, + { + $unwind: "$shipper", + }, + { + $group: { + _id: "$order._id", + user: { $first: "$order.user" }, + payment: { $first: "$order.payment" }, + shipmentAddress: { $first: "$order.shipmentAddress" }, + orderStatus: { $first: "$order.orderStatus" }, + createdAt: { $first: "$order.createdAt" }, + orderItems: { + $first: { + _id: "$_id", + order_id: "$order._id", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + }, + }, + shipmentItems: { + $push: { + product: "$shipmentItems.product", + variant: "$shipmentItems.variant", + quantity: "$shipmentItems.quantity", + cancelled_quantity: "$shipmentItems.cancelled_quantity", + returned_quantity: "$shipmentItems.returned_quantity", + selling_price: "$shipmentItems.selling_price", + retail_price: "$shipmentItems.retail_price", + discount_percent: "$shipmentItems.discount_percent", + shipmentCost: "$shipmentItems.shipmentCost", + }, + }, + }, + }, + + { + $addFields: { + "orderItems.shipmentItems": "$shipmentItems", + // totalPaymentPrice: { $add: ["$totalSellingPrice", "$totalShipmentCost"] }, + }, + }, + { + $match: priceQuery, + }, + { + $sort: { + "order.createdAt": -1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $project: { + shipmentItems: 0, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getSellerOrdersForAdminPanel(shopIds: Types.ObjectId[], queries: SellerOrdersQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const priceQuery: { [k: string]: { [op: string]: number } } = {}; + + if (queries.minPrice) { + priceQuery.totalPaymentPrice = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + if (priceQuery.totalPaymentPrice) { + priceQuery.totalPaymentPrice.$lte = queries.maxPrice; + } else { + priceQuery.totalPaymentPrice = { $lte: queries.maxPrice }; + } + } + + const docs = await this.model.aggregate([ + { + $match: { + shop: { $in: shopIds }, + }, + }, + { + $lookup: { + from: "shops", + foreignField: "_id", + localField: "shop", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "cartpayments", + localField: "order.payment", + foreignField: "_id", + as: "order.payment", + }, + }, + { + $unwind: "$order.payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "order.payment.payment_method", + foreignField: "_id", + as: "order.payment.payment_method", + }, + }, + { + $unwind: "$order.payment.payment_method", + }, + { + $match: { + ...(queries.status && { + "order.payment.paymentStatus": queries.status, + }), + ...(queries.shipperId && { + shipper: +queries.shipperId, + }), + ...(queries.since && { + "order.createdAt": { $gte: new Date(queries.since) }, + }), + }, + }, + { + $lookup: { + from: "users", + localField: "order.user", + foreignField: "_id", + as: "order.user", + }, + }, + { + $unwind: "$order.user", + }, + { + $unwind: "$shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "shipmentItems.product", + foreignField: "_id", + as: "shipmentItems.product", + }, + }, + { + $unwind: "$shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "shipmentItems.variant", + foreignField: "_id", + as: "shipmentItems.variant", + }, + }, + { + $unwind: "$shipmentItems.variant", + }, + { + $lookup: { + from: "shipments", + localField: "shipper", + foreignField: "_id", + as: "shipper", + }, + }, + { + $unwind: "$shipper", + }, + { + $group: { + _id: "$order._id", + user: { $first: "$order.user" }, + payment: { $first: "$order.payment" }, + shipmentAddress: { $first: "$order.shipmentAddress" }, + orderStatus: { $first: "$order.orderStatus" }, + createdAt: { $first: "$order.createdAt" }, + orderItems: { + $first: { + _id: "$_id", + order_id: "$order._id", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + }, + }, + shipmentItems: { + $push: { + product: "$shipmentItems.product", + variant: "$shipmentItems.variant", + quantity: "$shipmentItems.quantity", + cancelled_quantity: "$shipmentItems.cancelled_quantity", + returned_quantity: "$shipmentItems.returned_quantity", + selling_price: "$shipmentItems.selling_price", + retail_price: "$shipmentItems.retail_price", + discount_percent: "$shipmentItems.discount_percent", + shipmentCost: "$shipmentItems.shipmentCost", + }, + }, + // totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + // totalRetailPrice: { $sum: { $multiply: ["$shipmentItems.retail_price", "$shipmentItems.quantity"] } }, + // totalShipmentCost: { $sum: "$shipmentItems.shipmentCost" }, + // itemsCount: { $sum: "$shipmentItems.quantity" }, + }, + }, + + { + $addFields: { + "orderItems.shipmentItems": "$shipmentItems", + // totalPaymentPrice: { $add: ["$totalSellingPrice", "$totalShipmentCost"] }, + }, + }, + { + $match: priceQuery, + }, + { + $sort: { + "order.createdAt": -1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $project: { + shipmentItems: 0, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getOrderPriceRangeForAdminPanel() { + const docs = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $unwind: "$shipmentItems", + }, + { + $group: { + _id: "$order._id", + totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + }, + }, + { + $group: { + _id: null, + minPrice: { $min: "$totalSellingPrice" }, + maxPrice: { $max: "$totalSellingPrice" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + + async getOrderPriceRange(shopId: string) { + const docs = await this.model.aggregate([ + { + $match: { + shop: new Types.ObjectId(shopId), + }, + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $unwind: "$shipmentItems", + }, + { + $group: { + _id: "$order._id", + totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + }, + }, + { + $group: { + _id: null, + minPrice: { $min: "$totalSellingPrice" }, + maxPrice: { $max: "$totalSellingPrice" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + async getSellerOrderPriceRange(shopIds: Types.ObjectId[]) { + const docs = await this.model.aggregate([ + { + $match: { + shop: { $in: shopIds }, + }, + }, + { + $lookup: { + from: "shops", + foreignField: "_id", + localField: "shop", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $unwind: "$shipmentItems", + }, + { + $group: { + _id: "$order._id", + totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + }, + }, + { + $group: { + _id: null, + minPrice: { $min: "$totalSellingPrice" }, + maxPrice: { $max: "$totalSellingPrice" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + + async getSellerOrderDetail(orderId: number, shopId: string) { + return this.model.aggregate([ + { + $match: { + order: orderId, + shop: new Types.ObjectId(shopId), + }, + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "cartpayments", + localField: "order.payment", + foreignField: "_id", + as: "order.payment", + }, + }, + { + $unwind: "$order.payment", + }, + { + $lookup: { + from: "paymentmethods", + localField: "order.payment.payment_method", + foreignField: "_id", + as: "order.payment.payment_method", + }, + }, + { + $unwind: "$order.payment.payment_method", + }, + { + $lookup: { + from: "users", + localField: "order.user", + foreignField: "_id", + as: "order.user", + }, + }, + { + $unwind: "$order.user", + }, + { + $unwind: "$shipmentItems", + }, + { + $lookup: { + from: "products", + localField: "shipmentItems.product", + foreignField: "_id", + as: "shipmentItems.product", + }, + }, + { + $unwind: "$shipmentItems.product", + }, + { + $lookup: { + from: "productvariants", + localField: "shipmentItems.variant", + foreignField: "_id", + as: "shipmentItems.variant", + }, + }, + { + $unwind: "$shipmentItems.variant", + }, + { + $lookup: { + from: "warranties", + localField: "shipmentItems.variant.warranty", + foreignField: "_id", + as: "shipmentItems.variant.warranty", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "shipmentItems.variant.color", + foreignField: "_id", + as: "shipmentItems.variant.color", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "shipmentItems.variant.size", + foreignField: "_id", + as: "shipmentItems.variant.size", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "shipmentItems.variant.meterage", + foreignField: "_id", + as: "shipmentItems.variant.meterage", + }, + }, + { + $unwind: { + path: "$shipmentItems.variant.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shipments", + localField: "shipper", + foreignField: "_id", + as: "shipper", + }, + }, + { + $unwind: "$shipper", + }, + { + $group: { + _id: "$order._id", + user: { $first: "$order.user" }, + payment: { $first: "$order.payment" }, + shipmentAddress: { $first: "$order.shipmentAddress" }, + orderStatus: { $first: "$order.orderStatus" }, + createdAt: { $first: "$order.createdAt" }, + orderItems: { + $first: { + _id: "$_id", + order_id: "$order._id", + shop: "$shop", + shipper: "$shipper", + totalSellingPrice: "$totalSellingPrice", + totalRetailPrice: "$totalRetailPrice", + totalShipmentCost: "$totalShipmentCost", + totalPaymentPrice: "$totalPaymentPrice", + itemsCount: "$itemsCount", + trackCode: "$trackCode", + postingDate: "$postingDate", + postingReceipt: "$postingReceipt", + status: "$status", + }, + }, + shipmentItems: { + $push: { + _id: "$shipmentItems._id", + product: "$shipmentItems.product", + variant: "$shipmentItems.variant", + quantity: "$shipmentItems.quantity", + cancelled_quantity: "$shipmentItems.cancelled_quantity", + returned_quantity: "$shipmentItems.returned_quantity", + selling_price: "$shipmentItems.selling_price", + retail_price: "$shipmentItems.retail_price", + discount_percent: "$shipmentItems.discount_percent", + shipmentCost: "$shipmentItems.shipmentCost", + }, + }, + // totalSellingPrice: { $sum: { $multiply: ["$shipmentItems.selling_price", "$shipmentItems.quantity"] } }, + // totalRetailPrice: { $sum: { $multiply: ["$shipmentItems.retail_price", "$shipmentItems.quantity"] } }, + // totalShipmentCost: { $sum: "$shipmentItems.shipmentCost" }, + // itemsCount: { $sum: "$shipmentItems.quantity" }, + }, + }, + { + $addFields: { + "orderItems.shipmentItems": "$shipmentItems", + // totalPaymentPrice: { $add: ["$totalSellingPrice", "$totalShipmentCost"] }, + }, + }, + ]); + } + + async getSellerSalesStats(shopId: string, queries: { start?: string; end?: string }) { + // Parse the date range from queries, converting from Persian to Gregorian if necessary + const startDate = queries.start ? new Date(TimeService.convertPersianToGregorian(queries.start)) : null; + const endDate = queries.end ? new Date(TimeService.convertPersianToGregorian(queries.end)) : new Date(); + + // Build the match query to filter data within the date range and for the specific shop + const matchQuery: any = { + shop: new Types.ObjectId(shopId), + createdAt: { + ...(startDate ? { $gte: startDate } : {}), + $lte: endDate, + }, + }; + + const salesData = await this.model.aggregate([ + { $match: matchQuery }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { $unwind: "$order" }, + { + $group: { + _id: { + year: { $year: "$createdAt" }, + month: { $month: "$createdAt" }, + }, + totalSales: { $sum: "$totalSellingPrice" }, + totalNetSales: { $sum: "$totalRetailPrice" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + { $sort: { "_id.year": 1, "_id.month": 1 } }, + ]); + + // Format the aggregated sales data + const formattedSalesData = salesData.map((item) => { + const monthString = `${item._id.year}-${String(item._id.month).padStart(2, "0")}`; + return { + month: TimeService.PersianMonthName(monthString), + totalSales: item.totalSales, + totalNetSales: item.totalNetSales, + itemsSold: item.itemsSold, + averageSales: item.totalSales / (item.totalOrders || 1), + totalOrders: item.totalOrders, + }; + }); + + // Calculate overall stats + const totalSales = formattedSalesData.reduce((acc, curr) => acc + curr.totalSales, 0); + const totalNetSales = formattedSalesData.reduce((acc, curr) => acc + curr.totalNetSales, 0); + const totalOrders = formattedSalesData.reduce((acc, curr) => acc + curr.totalOrders, 0); + const itemsSold = formattedSalesData.reduce((acc, curr) => acc + curr.itemsSold, 0); + + return { + averageDailySales: totalSales / (formattedSalesData.length * 30 || 1), + totalSales, + totalNetSales, + totalOrders, + itemsSold, + startDate: startDate ? TimeService.convertGregorianToPersian(startDate) : null, + endDate: TimeService.convertGregorianToPersian(endDate), + totalMonths: formattedSalesData.length, + statsByMonth: formattedSalesData, + }; + } + + async getAllProcessingOrders() { + return await this.model.find({ status: "Processing" }).countDocuments(); + } + + async getDailyShipmentSalesReport() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + console.log(today); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999); + console.log({ today, startOfDay, endOfDay }); + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfDay, + $lte: endOfDay, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Since we only want today's stats, grouping by date isn't necessary + totalShipmentCost: { $sum: "$totalShipmentCost" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalShipmentCost: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getWeeklyShipmentSalesReport() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - 6); // 7 days ago + const endOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999); + + console.log({ today, startOfWeek, endOfWeek }); + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfWeek, + $lte: endOfWeek, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Since we only want today's stats, grouping by date isn't necessary + totalShipmentCost: { $sum: "$totalShipmentCost" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalShipmentCost: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getMonthlyShipmentSalesReport() { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1); + + console.log({ today, startOfMonth, endOfMonth }); + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Since we only want today's stats, grouping by date isn't necessary + totalShipmentCost: { $sum: "$totalShipmentCost" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalShipmentCost: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getAnnualShipmentSalesReport() { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); + const endOfYear = new Date(today.getFullYear() + 1, 0, 1); + + console.log({ today, startOfYear, endOfYear }); + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfYear, + $lte: endOfYear, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Since we only want today's stats, grouping by date isn't necessary + totalShipmentCost: { $sum: "$totalShipmentCost" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalShipmentCost: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getDailySalesReport() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + console.log(today); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999); + console.log({ today, startOfDay, endOfDay }); + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfDay, + $lte: endOfDay, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Since we only want today's stats, grouping by date isn't necessary + totalSales: { $sum: "$totalSellingPrice" }, + totalNetSales: { $sum: "$totalRetailPrice" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalSales: 0, + totalNetSales: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getWeeklySalesReport() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - 6); // 7 days ago + const endOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999); + + console.log({ today, startOfWeek, endOfWeek }); + + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: startOfWeek, + $lte: endOfWeek, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // Grouping by week + totalSales: { $sum: "$totalSellingPrice" }, + totalNetSales: { $sum: "$totalRetailPrice" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + const defaultData = { + _id: null, + totalSales: 0, + totalNetSales: 0, + itemsSold: 0, + totalOrders: 0, + }; + console.log(salesData); + return salesData.length > 0 ? salesData[0] : defaultData; + } + + async getFiveDaysAgoSalesReport() { + const today = new Date(); + today.setHours(0, 0, 0, 0); // Reset time to the start of the day + + const fiveDaysAgo = new Date(today); + fiveDaysAgo.setDate(today.getDate() - 4); // 5 days ago + + const salesData = await this.model.aggregate([ + { + $match: { + createdAt: { + $gte: fiveDaysAgo, + $lt: new Date(today.getTime() + 24 * 60 * 60 * 1000), // Include today + }, + status: { + $nin: [OrderItemsStatus.cancelled_shop, OrderItemsStatus.cancelled_system, OrderItemsStatus.Returned], + }, + }, + }, + { + $group: { + _id: { + $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }, + }, + totalSales: { $sum: "$totalSellingPrice" }, + }, + }, + { + $sort: { _id: 1 }, // Sort by date in ascending order + }, + { + $project: { + _id: 0, + date: "$_id", + price: "$totalSales", + }, + }, + { + $group: { + _id: null, + dates: { $push: "$date" }, + prices: { $push: "$price" }, + }, + }, + { + $project: { + _id: 0, + data: { + $map: { + input: { $range: [0, 5] }, + as: "day", + in: { + date: { + $dateToString: { + format: "%Y-%m-%d", + date: { + $dateSubtract: { + startDate: today, + unit: "day", + amount: "$$day", + }, + }, + }, + }, + price: { + $let: { + vars: { + index: { + $indexOfArray: [ + "$dates", + { + $dateToString: { + format: "%Y-%m-%d", + date: { $dateSubtract: { startDate: today, unit: "day", amount: "$$day" } }, + }, + }, + ], + }, + }, + in: { + $cond: { + if: { $gte: ["$$index", 0] }, + then: { $arrayElemAt: ["$prices", "$$index"] }, + else: 0, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + $unwind: "$data", + }, + { + $replaceRoot: { newRoot: "$data" }, + }, + ]); + + return salesData; + } + + async getMonthlySalesReport() { + const today = new Date(); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1); + + const salesData = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $match: { + createdAt: { + $gte: startOfMonth, + $lt: endOfMonth, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, // No need for grouping by date since it's filtered for the entire month + totalSales: { $sum: "$totalSellingPrice" }, + totalNetSales: { $sum: "$totalRetailPrice" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + return salesData.length > 0 ? salesData[0] : null; + } + + async getAnnualSalesReport() { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); + const endOfYear = new Date(today.getFullYear() + 1, 0, 1); + + const salesData = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $match: { + createdAt: { + $gte: startOfYear, + $lt: endOfYear, + }, + status: { + $nin: ["cancelled_shop", "cancelled_system", "Returned"], + }, + }, + }, + { + $group: { + _id: null, + totalSales: { $sum: "$totalSellingPrice" }, + totalNetSales: { $sum: "$totalRetailPrice" }, + itemsSold: { $sum: "$itemsCount" }, + totalOrders: { $sum: 1 }, + }, + }, + ]); + + return salesData.length > 0 ? salesData[0] : null; + } + + async getTopSellingProducts(limit = 5) { + const topProducts = await this.model.aggregate([ + { $unwind: "$shipmentItems" }, + { + $group: { + _id: "$shipmentItems.product", + totalSold: { $sum: "$shipmentItems.quantity" }, + }, + }, + { $sort: { totalSold: -1 } }, + { $limit: limit }, + { + $lookup: { + from: "products", + localField: "_id", + foreignField: "_id", + as: "productDetails", + }, + }, + { $unwind: "$productDetails" }, + { + $lookup: { + from: "shops", + localField: "productDetails.shop", + foreignField: "_id", + as: "productDetails.shop", + }, + }, + { + $unwind: "$productDetails.shop", + }, + { + $project: { + _id: 0, + productId: "$_id", + totalSold: 1, + productDetails: { + _id: 1, + title_fa: 1, + title_en: 1, + model: 1, + description: 1, + shop: { + _id: 1, + shopName: 1, + }, + }, + }, + }, + ]); + + return topProducts; + } +} +//**************************************************** */ +//**************************************************** */ + +function createOrderItemRepo(): OrderItemRepo { + return new OrderItemRepo(); +} + +function createOrderRepo(): OrderRepository { + return new OrderRepository(); +} + +export { OrderRepository, createOrderRepo, createOrderItemRepo, OrderItemRepo }; diff --git a/src/modules/order/order.service.ts b/src/modules/order/order.service.ts new file mode 100644 index 0000000..42ccd20 --- /dev/null +++ b/src/modules/order/order.service.ts @@ -0,0 +1,444 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, startSession } from "mongoose"; + +import { InsertTrackCodeDTO, InsertTrackCodeGroupDTO } from "./DTO/insertTrackCode.dto"; +import { ReceivedOrderItemDTO } from "./DTO/received.dto"; +import { SellerOrdersDTO } from "./DTO/sellerOrder.dto"; +import { UserOrderDTO } from "./DTO/userOrder.dto"; +import { IShipmentAddress } from "./models/Abstraction/IOrder"; +import { IOrderItem } from "./models/Abstraction/IOrderItem"; +import { OrderItemRepo, OrderRepository } from "./order.repository"; +import { AddressMessage, OrderMessage, ShopMessage, UserMessage } from "../../common/enums/message.enum"; +import { OrderItemsStatus, OrdersStatus } from "../../common/enums/order.enum"; +import { OrderStatusQuery, SellerOrdersQueries } from "../../common/types/query.type"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { OrderQueue } from "../../queues/order/OrderQueue"; +import { EmailService } from "../../utils/email.service"; +import { AddressService } from "../address/address.service"; +import { ICity } from "../address/models/city.model"; +import { IProvince } from "../address/models/province.model"; +import { ICartShipmentItem } from "../cart/models/Abstraction/ICartShipmentItem"; +import { ICartPayment } from "../payment/models/Abstraction/IPayments"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; +import { IUser } from "../user/models/Abstraction/IUser"; +import { WalletService } from "../wallet/wallet.service"; + +@injectable() +class OrderService { + @inject(IOCTYPES.OrderRepository) private orderRepo: OrderRepository; + @inject(IOCTYPES.OrderItemRepo) private orderItemRepo: OrderItemRepo; + @inject(IOCTYPES.ShopRepo) private shopRepo: ShopRepo; + @inject(IOCTYPES.AddressService) private addressService: AddressService; + @inject(IOCTYPES.WalletService) private walletService: WalletService; + + //####################################################### + //####################################################### + async updateOrderStatus(orderId: number, status: OrdersStatus, session: ClientSession) { + return await this.orderRepo.model.findByIdAndUpdate(orderId, { orderStatus: status }, { session }); + } + //####################################################### + //####################################################### + async getOrderItemByOrderId(orderId: number, session: ClientSession) { + return await this.orderItemRepo.model.find({ order: orderId }, null, { session }); + } + //####################################################### + //####################################################### + async getOrderByPaymentId(paymentId: string, session: ClientSession) { + return await this.orderRepo.model.findOne({ payment: paymentId }).session(session); + } + //####################################################### + //####################################################### + async createOrder(user: IUser, payment: ICartPayment, cartShipmentItems: ICartShipmentItem[], session: ClientSession) { + if (!user.address) throw new BadRequestError([AddressMessage.UserShouldHaveAddress, AddressMessage.NotFound]); + const userAddress = await this.addressService.getUserAddress(user.address.toString()); + + const city = userAddress.city as unknown as ICity; + const province = userAddress.province as unknown as IProvince; + + const shipmentAddress: IShipmentAddress = { + address: userAddress.address, + city: city.name, + province: province.name, + phone: user.phoneNumber, + plaque: userAddress.plaque, + postalCode: userAddress.postalCode, + }; + + const order = await this.orderRepo.model.create( + [ + { + user: user._id.toString(), + payment: payment._id, + shipmentAddress, + }, + ], + { session }, + ); + + const orderItemsData: IOrderItem[] = cartShipmentItems.map((item) => ({ + order: order[0]._id, + shop: item.shop, + shipmentItems: item.shipmentItems, + shipper: item.shipper, + totalSellingPrice: item.totalSellingPrice, + totalPaymentPrice: item.totalPaymentPrice, + totalRetailPrice: item.totalRetailPrice, + totalCouponDiscount: item.totalCouponDiscount, + totalShipmentCost: item.totalShipmentCost, + itemsCount: item.itemsCount, + status: OrderItemsStatus.Processing, + })); + + const orderItems = await this.orderItemRepo.model.create(orderItemsData, { session }); + + // Add the order to the queue with a 5-minute delay + await OrderQueue.addOrderToQueue(order[0]._id, 5 * 60 * 1000); + + return { + order: order[0], + orderItems, + }; + } + //####################################################### + //####################################################### + async getAdminOrders(sellerId: string, queries: SellerOrdersQueries) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const priceRange = await this.orderItemRepo.getOrderPriceRange(shop._id.toString()); + const { docs, count } = await this.orderItemRepo.getOrdersForSellerPanel(shop._id.toString(), queries); + + const orders = docs.map((doc: any) => SellerOrdersDTO.transformOrder(doc)); + + return { orders, count, priceRange }; + } + //####################################################### + //####################################################### + async getSellerOrders(ownerRef: OwnerRef, queries: SellerOrdersQueries) { + const shops = await this.shopRepo.model.find({ ownerRef }).select("_id"); + const shopIds = shops.map((shop) => shop._id); + const priceRange = await this.orderItemRepo.getSellerOrderPriceRange(shopIds); + const { docs, count } = await this.orderItemRepo.getSellerOrdersForAdminPanel(shopIds, queries); + + const orders = docs.map((doc: any) => SellerOrdersDTO.transformOrder(doc)); + + return { orders, count, priceRange }; + } + //####################################################### + //####################################################### + async getOrders(sellerId: string, queries: SellerOrdersQueries, orderIds?: string[] | string) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const priceRange = await this.orderItemRepo.getOrderPriceRange(shop._id.toString()); + + const { docs, count } = await this.orderItemRepo.getOrdersForSellerPanel(shop._id.toString(), queries, orderIds); + + const orders = docs.map((doc: any) => SellerOrdersDTO.transformOrder(doc)); + + return { orders, count, priceRange }; + } + //####################################################### + //####################################################### + async insertTrackCode(orderId: number, sellerId: string, insertDto: InsertTrackCodeDTO) { + const { orderItem } = await this.validateSellerOrder(orderId, sellerId); + + orderItem.trackCode = insertDto.trackCode; + orderItem.postingDate = insertDto.postingDate; + orderItem.postingReceipt = insertDto.postingReceipt; + orderItem.status = OrderItemsStatus.Shipped; + + await orderItem.save(); + + return { + message: OrderMessage.TrackingDetailInserted, + orderItem, + }; + } + //####################################################### + + async insertTrackCodeGroup(sellerId: string, insertDto: InsertTrackCodeGroupDTO) { + const session = await startSession(); + session.startTransaction(); + + try { + const orderIds = insertDto.trackCodes.map((tc) => tc.orderId); + + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const orderItems = await this.orderItemRepo.model.find({ order: { $in: orderIds }, shop: shop._id }).session(session); + + if (orderItems.length !== orderIds.length) throw new BadRequestError(OrderMessage.OrderItemsNotFound); + + // Prepare bulk operations + const bulkOps = insertDto.trackCodes.map((trackCodeDto) => ({ + updateOne: { + filter: { + order: trackCodeDto.orderId, + shop: shop._id, + }, + update: { + $set: { + trackCode: trackCodeDto.trackCode, + postingDate: trackCodeDto.postingDate, + postingReceipt: trackCodeDto.postingReceipt, + status: OrderItemsStatus.Shipped, + }, + }, + }, + })); + + // Execute bulk operation + await this.orderItemRepo.model.bulkWrite(bulkOps, { session }); + + await session.commitTransaction(); + + return { + message: OrderMessage.TrackingDetailInserted, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################################################### + //####################################################### + async insertNativeShopTrackCode(orderId: number, adminId: string, insertDto: InsertTrackCodeDTO) { + const { orderItem } = await this.validateSellerOrder(orderId, adminId); + + orderItem.trackCode = insertDto.trackCode; + orderItem.postingDate = insertDto.postingDate; + orderItem.postingReceipt = insertDto.postingReceipt; + orderItem.status = OrderItemsStatus.Shipped; + + await orderItem.save(); + + return { + message: OrderMessage.TrackingDetailInserted, + orderItem, + }; + } + //####################################################### + //####################################################### + async trackOrder(orderId: number, userId: string, userEmail: string) { + if (isNaN(orderId)) throw new BadRequestError(OrderMessage.NotFound); + if (!userEmail) throw new BadRequestError(UserMessage.EmailNotExist); + + const order = await this.orderRepo.model.findOne({ _id: orderId, user: userId }); + if (!order) throw new BadRequestError(OrderMessage.NotFound); + + const trackingDetails = await this.orderItemRepo.getOrderTrackingDetails(orderId); + + if (!trackingDetails.length) throw new BadRequestError(OrderMessage.TrackingCodeNotFound); + + const trackingInfo = trackingDetails.map((item: any) => ({ + trackCode: item.trackCode, + status: item.status, + shipper: item.shipper.name, + postingDate: item.postingDate, + })); + + await EmailService.sendOrderTrackingEmail(userEmail, { orderId, trackingInfo }); + + return { + message: OrderMessage.TrackingEmailSent, + }; + } + //####################################################### + + async setOrderItemReceived(orderId: number, userId: string, receivedOrderItemDto: ReceivedOrderItemDTO) { + const session = await startSession(); + session.startTransaction(); + try { + await this.validateUserOrder(orderId, userId); + + const orderItem = await this.orderItemRepo.model.findById(receivedOrderItemDto.orderItemId).session(session); + + if (!orderItem) throw new BadRequestError(`OrderItem with ID ${receivedOrderItemDto.orderItemId} not found.`); + + if (orderItem.status === OrderItemsStatus.Delivered) throw new BadRequestError(OrderMessage.ItemAlreadyReceived); + + if (orderItem.status !== OrderItemsStatus.Shipped) throw new BadRequestError(OrderMessage.ItemNotShipped); + + orderItem.status = OrderItemsStatus.Delivered; + + const shop = await this.shopRepo.model.findById(orderItem.shop.toString()).session(session); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFoundById); + + if (shop.ownerRef === OwnerRef.SELLER) { + await this.walletService.creditSellerWalletForOrder(shop.owner.toString(), orderId, orderItem.totalPaymentPrice, session); + } + + await orderItem.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { + message: OrderMessage.ItemReceived, + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + //####################################################### + //####################################################### + async getSellerOrderDetail(orderId: number, sellerId: string) { + const { shop } = await this.validateSellerOrder(orderId, sellerId); + + const doc = await this.orderItemRepo.getSellerOrderDetail(orderId, shop._id.toString()); + const order = SellerOrdersDTO.transformOrder(doc[0]); + + return { order }; + } + //####################################################### + //####################################################### + async getUserOrderDetail(orderId: number, userId: string) { + await this.validateUserOrder(orderId, userId); + + const doc = await this.orderRepo.getUserOrderDetail(orderId, userId); + + const order = UserOrderDTO.transformOrder(doc[0]); + + return { order }; + } + + async getUserOrderItemsDetail(orderId: number, orderItemId: string, userId: string) { + await this.validateUserOrder(orderId, userId); + + const doc = await this.orderItemRepo.getUserOrderItemsDetail(orderId, orderItemId); + + const orderItems = UserOrderDTO.transformOrder(doc[0]); + + return { orderItems }; + } + //####################################################### + //####################################################### + async getSellerSalesStats(sellerId: string, queries: { start: string; end: string }) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const saleStats = await this.orderItemRepo.getSellerSalesStats(shop._id.toString(), queries); + return { saleStats }; + } + + //####################################################### + //####################################################### + async getUserOrders(userId: string, statusQuery: OrderStatusQuery) { + // const orders = await this.orderRepo.model.find({ user: userId }); + const docs = await this.orderRepo.getUserOrders(userId, statusQuery); + const orders = docs.map((doc) => UserOrderDTO.transformOrder(doc)); + const mappedOrders = orders.map((order) => this.mapUserOrderItems(order)); + return { mappedOrders, orders }; + } + + //helper methods + //####################################################### + //####################################################### + private async validateSellerOrder(orderId: number, sellerId: string) { + const order = await this.orderRepo.findById(orderId); + if (!order) throw new BadRequestError(OrderMessage.NotFound); + + const shop = await this.shopRepo.model.findOne({ owner: sellerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const orderItem = await this.orderItemRepo.model.findOne({ shop: shop._id.toString(), order: order._id }); + if (!orderItem) throw new BadRequestError(OrderMessage.OrderItemsNotFound); + return { order, orderItem, shop }; + } + //####################################################### + //####################################################### + private async validateUserOrder(orderId: number, userId: string) { + const order = await this.orderRepo.model.findOne({ _id: orderId, user: userId }); + if (!order) throw new BadRequestError(OrderMessage.NotFound); + + const orderItems = await this.orderItemRepo.model.find({ order: order._id }); + if (!orderItems || orderItems.length === 0) throw new BadRequestError(OrderMessage.OrderItemsNotFound); + + return { order, orderItems }; + } + + //####################################### + //####################################### + private mapUserOrderItems(userOrder: UserOrderDTO) { + const shipmentItems = userOrder.orderItems.map((orderItem) => { + return orderItem.shipmentItems; + }); + return { + orderId: userOrder._id, + user: userOrder.user, + orderItems: shipmentItems.flat(Infinity), + payment: userOrder.payment, + shipmentAddress: userOrder.shipmentAddress, + orderStatus: userOrder.orderStatus, + createdAt: userOrder.createdAt, + }; + } + + //####################################### + //####################################### + async getDailySalesReport() { + const sales = await this.orderItemRepo.getDailySalesReport(); + return sales; + } + + async getWeeklySalesReport() { + const sales = await this.orderItemRepo.getWeeklySalesReport(); + return sales; + } + + async getMonthlySalesReport() { + const sales = await this.orderItemRepo.getMonthlySalesReport(); + return sales; + } + + async getAnnualSalesReport() { + const sales = await this.orderItemRepo.getAnnualSalesReport(); + return sales; + } + + async getDailyShipmentSalesReport() { + const sales = await this.orderItemRepo.getDailyShipmentSalesReport(); + return sales; + } + + async getWeeklyShipmentSalesReport() { + const sales = await this.orderItemRepo.getWeeklyShipmentSalesReport(); + return sales; + } + + async getMonthlyShipmentSalesReport() { + const sales = await this.orderItemRepo.getMonthlyShipmentSalesReport(); + return sales; + } + + async getAnnualShipmentSalesReport() { + const sales = await this.orderItemRepo.getAnnualShipmentSalesReport(); + return sales; + } + + async getFiveDaysAgoSalesReport() { + const sales = await this.orderItemRepo.getFiveDaysAgoSalesReport(); + return sales; + } + + async getTopSellingProductsS() { + const sales = await this.orderItemRepo.getTopSellingProducts(); + return sales; + } + + async getAllProcessingOrders() { + const sales = await this.orderItemRepo.getAllProcessingOrders(); + return sales; + } +} + +export { OrderService }; diff --git a/src/modules/payment/DTO/PRCheckout.dto.ts b/src/modules/payment/DTO/PRCheckout.dto.ts new file mode 100644 index 0000000..cfb02c5 --- /dev/null +++ b/src/modules/payment/DTO/PRCheckout.dto.ts @@ -0,0 +1,43 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +// import { GatewayProvider } from "../../../common/enums/payment.enum"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductRequestModel } from "../../product/models/productRequest.model"; +import { PaymentMethodModel } from "../models/paymentMethod.model"; + +export class PRCheckoutDTO { + @Expose() + @IsNotEmpty() + @IsValidId(ProductRequestModel) + @ApiProperty({ type: "boolean", description: "the product request id", example: "6746dbdb484ba50610e6bb02" }) + requestId: string; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(PaymentMethodModel) + @ApiProperty({ type: "string", description: "the payment method id ", example: "66d9ab8b45818616a48bc322" }) + payment_method_id: string; + + // @Expose() + // @IsOptional() + // @IsNotEmpty() + // @IsBoolean() + // @ApiProperty({ type: "boolean", description: "send as true if box selected", example: true }) + // request_photo: boolean; + + // @Expose() + // @IsOptional() + // @IsNotEmpty() + // @IsNumber() + // @ApiProperty({ type: "number", description: "send if photo uploaded", example: 5 }) + // photos_count: number; + + // @Expose() + // @IsString() + // @IsEnum(GatewayProvider) + // @ApiProperty({ type: "string", description: "the payment provider name", example: "zarinpal || asanpardakht" }) + // GatewayProvider: GatewayProvider; +} diff --git a/src/modules/payment/DTO/cartCheckout.dto.ts b/src/modules/payment/DTO/cartCheckout.dto.ts new file mode 100644 index 0000000..6aa280d --- /dev/null +++ b/src/modules/payment/DTO/cartCheckout.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { PaymentMethodModel } from "../models/paymentMethod.model"; + +export class CartCheckoutDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(PaymentMethodModel) + @ApiProperty({ type: "string", description: "the payment method id ", example: "66d9ab8b45818616a48bc322" }) + payment_method_id: string; +} diff --git a/src/modules/payment/DTO/payment.dto.ts b/src/modules/payment/DTO/payment.dto.ts new file mode 100644 index 0000000..460d801 --- /dev/null +++ b/src/modules/payment/DTO/payment.dto.ts @@ -0,0 +1,87 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; + +import { PaymentStatus } from "../../../common/enums/order.enum"; +import { TimeService } from "../../../utils/time.service"; +import { MiniUserDTO } from "../../user/DTO/user.dto"; +import { ICartPayment } from "../models/Abstraction/IPayments"; +class PriceDetailDTO { + @Expose() + shipping_cost: number; + + @Expose() + process_cost: number; + + @Expose() + total_retail_price: number; + + @Expose() + total_payable_price: number; + + @Expose() + total_discount: number; + + @Expose() + coupon_discount: number; +} + +class PaymentMethodDTO { + @Expose() + _id: string; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + description: string; + + @Expose() + provider: string; + + @Expose() + isActive: boolean; + + @Expose() + type: string; +} +//********************************************** +//********************************************** +export class PaymentDTO { + @Expose() + _id: string; + + @Expose() + user: MiniUserDTO; + + @Expose() + transaction_id: string; + + @Expose() + @Type(() => PaymentMethodDTO) + payment_method: PaymentMethodDTO; + + @Expose() + @Type(() => PriceDetailDTO) + priceDetails: PriceDetailDTO; + + @Expose() + totalPrice: number; + + @Expose() + paymentStatus: PaymentStatus; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformPayment(data: ICartPayment): PaymentDTO { + const paymentDTO = plainToClass(PaymentDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return paymentDTO; + } +} diff --git a/src/modules/payment/models/Abstraction/IPaymentMethod.ts b/src/modules/payment/models/Abstraction/IPaymentMethod.ts new file mode 100644 index 0000000..79ae0d0 --- /dev/null +++ b/src/modules/payment/models/Abstraction/IPaymentMethod.ts @@ -0,0 +1,13 @@ +import { Types } from "mongoose"; + +import { PaymentMethodType } from "../../../../common/enums/payment.enum"; + +export interface IPaymentMethod { + _id: Types.ObjectId; + title_fa: string; + title_en: string; + description: string; + provider: string; + isActive: boolean; + type: PaymentMethodType; +} diff --git a/src/modules/payment/models/Abstraction/IPayments.ts b/src/modules/payment/models/Abstraction/IPayments.ts new file mode 100644 index 0000000..b36372f --- /dev/null +++ b/src/modules/payment/models/Abstraction/IPayments.ts @@ -0,0 +1,42 @@ +import { Types } from "mongoose"; + +import { PaymentStatus } from "../../../../common/enums/order.enum"; + +// Purchase schema interface +export interface ICartPayment { + _id: Types.ObjectId; + user_id: Types.ObjectId; + authority: string; + transaction_id: number; + payment_method: Types.ObjectId; + priceDetails: IPriceDetails; + totalPrice: number; + paymentStatus: PaymentStatus; +} + +export interface IPriceDetails { + shipping_cost: number; + process_cost: number; + total_retail_price: number; + total_payable_price: number; + total_discount: number; + coupon_discount: number; +} +//===========> product request payment + +export interface IPRPriceDetails { + insertion_price: number; + unboxing_price: number; + photography_price: number; + expertReviews_price: number; +} +export interface IProductRequestPayment { + _id: Types.ObjectId; + seller_id: Types.ObjectId; + authority: string; + transaction_id: number; + payment_method: Types.ObjectId; + priceDetails: IPRPriceDetails; + totalPrice: number; + paymentStatus: PaymentStatus; +} diff --git a/src/modules/payment/models/paymentMethod.model.ts b/src/modules/payment/models/paymentMethod.model.ts new file mode 100644 index 0000000..225d4f3 --- /dev/null +++ b/src/modules/payment/models/paymentMethod.model.ts @@ -0,0 +1,20 @@ +import { Schema, model } from "mongoose"; + +import { IPaymentMethod } from "./Abstraction/IPaymentMethod"; +import { PaymentMethodType } from "../../../common/enums/payment.enum"; + +const PaymentMethodSchema = new Schema( + { + title_fa: { type: String, required: true }, + title_en: { type: String, required: true }, + description: { type: String, required: false, default: null }, + provider: { type: String, required: false, default: null }, + isActive: { type: Boolean, default: true }, + type: { type: String, enum: PaymentMethodType, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const PaymentMethodModel = model("PaymentMethod", PaymentMethodSchema); + +export { PaymentMethodModel }; diff --git a/src/modules/payment/models/payments.model.ts b/src/modules/payment/models/payments.model.ts new file mode 100644 index 0000000..a195311 --- /dev/null +++ b/src/modules/payment/models/payments.model.ts @@ -0,0 +1,60 @@ +import { Schema, model } from "mongoose"; + +import { ICartPayment, IPRPriceDetails, IPriceDetails, IProductRequestPayment } from "./Abstraction/IPayments"; +import { PaymentStatus } from "../../../common/enums/order.enum"; + +const PaymentPriceDetails = new Schema( + { + shipping_cost: { type: Number, required: true }, + process_cost: { type: Number, required: true }, + total_retail_price: { type: Number, required: true }, + total_discount: { type: Number, default: 0 }, + coupon_discount: { type: Number, default: 0 }, + total_payable_price: { type: Number, required: true }, + }, + { _id: false }, +); + +const CartPaymentSchema = new Schema( + { + user_id: { type: Schema.Types.ObjectId, ref: "User", required: true }, + authority: { type: String, required: true }, + transaction_id: { type: Number, default: null }, + payment_method: { type: Schema.Types.ObjectId, ref: "PaymentMethod", required: true }, + priceDetails: { type: PaymentPriceDetails, required: true }, + totalPrice: { type: Number, required: true }, + paymentStatus: { type: String, enum: PaymentStatus, default: PaymentStatus.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); +const CartPaymentModel = model("CartPayment", CartPaymentSchema); + +//=======================================> product request payment +const PRPriceDetailSchema = new Schema( + { + insertion_price: { type: Number, required: true }, + unboxing_price: { type: Number, required: true }, + photography_price: { type: Number, required: true }, + expertReviews_price: { type: Number, required: true }, + }, + { + _id: false, + }, +); + +const PRPaymentSchema = new Schema( + { + seller_id: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + authority: { type: String, required: true }, + transaction_id: { type: Number, default: null }, + payment_method: { type: Schema.Types.ObjectId, ref: "PaymentMethod", required: true }, + priceDetails: { type: PRPriceDetailSchema }, + totalPrice: { type: Number, required: true }, + paymentStatus: { type: String, enum: PaymentStatus, default: PaymentStatus.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const PRPaymentModel = model("PRPayment", PRPaymentSchema); + +export { CartPaymentModel, PRPaymentModel }; diff --git a/src/modules/payment/payment.controller.ts b/src/modules/payment/payment.controller.ts new file mode 100644 index 0000000..640b56f --- /dev/null +++ b/src/modules/payment/payment.controller.ts @@ -0,0 +1,129 @@ +import { Request, Response } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody, requestParam, response } from "inversify-express-utils"; + +import { PaymentService } from "./providers/payment.service"; +import { HttpStatus } from "../../common"; +import { CartCheckoutDTO } from "./DTO/cartCheckout.dto"; +import { PRCheckoutDTO } from "./DTO/PRCheckout.dto"; +import { PRPaymentService } from "./providers/PR-payment.service"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { GatewayProvider } from "../../common/enums/payment.enum"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { verifyPaymentTemplate } from "../../template/payment"; +import { verifyPRPaymentTemplate } from "../../template/PRPayment"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/payment") +@ApiTags("Payment") +class PaymentController extends BaseController { + @inject(IOCTYPES.PaymentService) paymentService: PaymentService; + @inject(IOCTYPES.PRPaymentService) PRPaymentService: PRPaymentService; + // + + @ApiOperation("get payment info for current session user ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("", Guard.authUser()) + public async getPaymentInfo(@request() req: Request) { + const user = req.user as IUser; + const data = await this.paymentService.getPaymentInfoS(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get payment method provider") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/methods") + public async getPaymentMethods() { + return this.response(await this.paymentService.getPaymentMethods()); + } + + @ApiOperation("get pricing of product insertion") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/pricing") + public async getPricingDetails() { + const data = await this.paymentService.getPricingDetails(); + return this.response(data); + } + + @ApiOperation("create a payment for current session user ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CartCheckoutDTO) + @ApiAuth() + @httpPost("/checkout/cart", Guard.authUser()) + public async createCartPayment(@requestBody() cartCheckoutDto: CartCheckoutDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.paymentService.createCartPaymentS(cartCheckoutDto, user); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("create a payment for current session seller for product request payment ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(PRCheckoutDTO) + @ApiAuth() + @httpPost("/checkout/PR", Guard.authSeller()) + public async createProductRequestPayment(@requestBody() PRCheckoutDto: PRCheckoutDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.PRPaymentService.processPRCheckout(PRCheckoutDto, seller); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("retry an exist payment for current session user ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("paymentId", "payment id of the order", true) + @ApiAuth() + @httpGet("/:paymentId/retry", Guard.authUser()) + public async retryPayment(@request() req: Request, @requestParam("paymentId") paymentId: string) { + const user = req.user as IUser; + const data = await this.paymentService.retryPayment(paymentId, user); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("verify a payment for current session user for cartCheckout") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("gateway", "the name of api gateway", true) + @ApiQuery("Authority", "authority", true) + @ApiQuery("Status", "status", true) + @httpGet("/verify/:gateway/cart") + public async VerifyCartCheckout( + @requestParam("gateway") gateway: string, + @queryParam("Authority") authority: string, + @queryParam("Status") paymentStatus: string, + @response() res: Response, + ) { + const verifyCallbackData = { + status: paymentStatus, + authority: authority, + }; + const data = await this.paymentService.verifyCartPayment(gateway as GatewayProvider, verifyCallbackData); + const html = verifyPaymentTemplate(data); + return res.send(html); + // return this.response(data); + } + + @ApiOperation("verify a payment for current session seller for product request checkout") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("gateway", "the name of api gateway", true) + @ApiQuery("Authority", "authority", true) + @ApiQuery("Status", "status", true) + @httpGet("/verify/:gateway/PR") + public async VerifyPRCheckout( + @requestParam("gateway") gateway: string, + @queryParam("Authority") authority: string, + @queryParam("Status") paymentStatus: string, + @response() res: Response, + ) { + const verifyCallbackData = { + status: paymentStatus, + authority: authority, + }; + const data = await this.PRPaymentService.verifyPRPayment(gateway as GatewayProvider, verifyCallbackData); + const html = verifyPRPaymentTemplate(data); + return res.send(html); + } +} + +export { PaymentController }; diff --git a/src/modules/payment/payment.repository.ts b/src/modules/payment/payment.repository.ts new file mode 100644 index 0000000..55a1b9b --- /dev/null +++ b/src/modules/payment/payment.repository.ts @@ -0,0 +1,129 @@ +import { IPaymentMethod } from "./models/Abstraction/IPaymentMethod"; +import { ICartPayment, IProductRequestPayment } from "./models/Abstraction/IPayments"; +import { PaymentMethodModel } from "./models/paymentMethod.model"; +import { CartPaymentModel, PRPaymentModel } from "./models/payments.model"; +import { BaseRepository } from "../../common/base/repository"; +import { PaymentsQueries } from "../../common/types/query.type"; + +class CartPaymentRepository extends BaseRepository { + constructor() { + super(CartPaymentModel); + } + + async getPaymentsForAdminPanel(queries: PaymentsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const priceQuery: { [k: string]: { [op: string]: number } } = {}; + + if (queries.minPrice) { + priceQuery.totalPaymentPrice = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + if (priceQuery.totalPaymentPrice) { + priceQuery.totalPaymentPrice.$lte = queries.maxPrice; + } else { + priceQuery.totalPaymentPrice = { $lte: queries.maxPrice }; + } + } + + const docs = await this.model.aggregate([ + { + $lookup: { + from: "paymentmethods", + localField: "payment_method", + foreignField: "_id", + as: "payment_method", + }, + }, + { + $unwind: "$payment_method", + }, + { + $match: { + ...(queries.status && { + paymentStatus: queries.status, + }), + ...(queries.since && { + createdAt: { $gte: new Date(queries.since) }, + }), + }, + }, + { + $lookup: { + from: "users", + localField: "user_id", + foreignField: "_id", + as: "user", + }, + }, + { + $unwind: "$user", + }, + { + $addFields: { + user: "$user", + }, + }, + { + $match: priceQuery, + }, + { + $sort: { + createdAt: -1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]); + console.log(docs[0]); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } +} + +function createCartPaymentRepo(): CartPaymentRepository { + return new CartPaymentRepository(); +} +//===============================================> +class PRPaymentRepository extends BaseRepository { + constructor() { + super(PRPaymentModel); + } +} + +function createPRPaymentRepo(): PRPaymentRepository { + return new PRPaymentRepository(); +} +//=================================> +class PaymentMethodRepo extends BaseRepository { + constructor() { + super(PaymentMethodModel); + } +} + +function createPaymentMethodRepo(): PaymentMethodRepo { + return new PaymentMethodRepo(); +} + +export { + CartPaymentRepository, + createCartPaymentRepo, + PRPaymentRepository, + createPRPaymentRepo, + PaymentMethodRepo, + createPaymentMethodRepo, +}; diff --git a/src/modules/payment/providers/PR-payment.service.ts b/src/modules/payment/providers/PR-payment.service.ts new file mode 100644 index 0000000..aadd185 --- /dev/null +++ b/src/modules/payment/providers/PR-payment.service.ts @@ -0,0 +1,195 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, startSession } from "mongoose"; + +import { PaymentMessage, PricingMessage, ProductRequestMessage } from "../../../common/enums/message.enum"; +import { PaymentStatus } from "../../../common/enums/order.enum"; +import { GatewayProvider } from "../../../common/enums/payment.enum"; +import { BadRequestError } from "../../../core/app/app.errors"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { PaymentGateway } from "../../IPG/PaymentGateway"; +import { PricingTypeEnum } from "../../pricing/models/Abstraction/IPricing"; +import { PricingRepository } from "../../pricing/pricing.repository"; +import { ProductService } from "../../product/providers/product.service"; +import { ProductRequestRepo } from "../../product/Repository/productRequest"; +import { ISeller } from "../../seller/models/Abstraction/ISeller"; +import { PRCheckoutDTO } from "../DTO/PRCheckout.dto"; +import { PRPaymentRepository } from "../payment.repository"; + +@injectable() +export class PRPaymentService { + @inject(IOCTYPES.ProductService) productService: ProductService; + @inject(IOCTYPES.PricingRepo) priceRepo: PricingRepository; + @inject(IOCTYPES.PaymentGateway) paymentGateway: PaymentGateway; + @inject(IOCTYPES.PRPaymentRepo) PRPaymentRepo: PRPaymentRepository; + @inject(IOCTYPES.ProductRequestRepo) productRequestRepo: ProductRequestRepo; + + //################################################################## + //################################################################## + async processPRCheckout(PRCheckoutDto: PRCheckoutDTO, seller: ISeller) { + const session = await startSession(); + session.startTransaction(); + try { + const productRequest = await this.productRequestRepo.model.findOne({ _id: PRCheckoutDto.requestId, seller: seller._id }); + if (!productRequest) throw new BadRequestError(ProductRequestMessage.NotFound); + + const insertFee = await this.priceRepo.model.findOne({ type: PricingTypeEnum.INSERT_FEE }).lean(); + if (!insertFee) throw new BadRequestError(PricingMessage.InsertFeeNotFound); + + const photographyFee = await this.priceRepo.model.findOne({ type: PricingTypeEnum.PHOTOGRAPHY_FEE }).lean(); + if (!photographyFee) throw new BadRequestError(PricingMessage.PhotographyFee); + + const unboxingVideoFee = await this.priceRepo.model.findOne({ type: PricingTypeEnum.UNBOXING_VIDEO_FEE }).lean(); + if (!unboxingVideoFee) throw new BadRequestError(PricingMessage.UnboxingVideoFee); + + const expertReview = await this.priceRepo.model.findOne({ type: PricingTypeEnum.EXPERT_REVIEWS_FEE }).lean(); + if (!expertReview) throw new BadRequestError(PricingMessage.ExpertReviewsFeeNotFound); + + let totalPrice = insertFee.price; + let photography_price = 0; + let unboxing_price = 0; + let expertReview_price = 0; + const description = PaymentMessage.PRPaymentDesc; + + if (productRequest.unboxingVideo) { + unboxing_price = unboxingVideoFee.price; + totalPrice += unboxing_price; + } + + if (productRequest.expertReview) { + expertReview_price = expertReview.price; + totalPrice += expertReview_price; + } + + if (productRequest.requestPhotography && productRequest.requestPhotosCount) { + const photographyPrice = photographyFee.price * productRequest.requestPhotosCount; + photography_price = photographyPrice; + totalPrice += photography_price; + } + + totalPrice += totalPrice * 0.1; + + const paymentData = await this.paymentGateway.requestPayment(GatewayProvider.Zarinpal, { + amount: totalPrice, + description, + email: seller.email, + mobile: seller.phoneNumber, + callbackPath: "PR", + }); + + const PRPayment = await this.PRPaymentRepo.model.create( + [ + { + seller_id: seller._id.toString(), + authority: paymentData.authority, + amount: totalPrice, + payment_method: PRCheckoutDto.payment_method_id, + priceDetails: { + insertion_price: insertFee.price, + photography_price, + unboxing_price, + expertReview_price, + }, + totalPrice, + }, + ], + { session }, + ); + productRequest.payment = PRPayment[0]._id; + await productRequest.save({ session }); + + await session.commitTransaction(); + + return { paymentData }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //################################################################## + //################################################################## + async verifyPRPayment(gateway: GatewayProvider, verifyCallbackData: { status: string; authority: string }) { + const { status, authority } = verifyCallbackData; + const session = await startSession(); + session.startTransaction(); + + try { + const paymentData = await this.findPaymentByAuthority(authority, session); + const productRequest = await this.findProductRequestByPaymentId(paymentData._id.toString(), session); + const PRId = productRequest._id.toString(); + + if (status !== "OK") { + await this.handlePaymentCancellation(paymentData._id.toString(), session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentNotSuccessful, paymentData._id.toString(), PRId); + } + + const verificationResult = await this.verifyPaymentWithGateway(gateway, authority, paymentData.totalPrice); + if (verificationResult.success) { + await this.handleSuccessPayment(paymentData._id.toString(), verificationResult.refId!, session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentSuccessful, paymentData._id.toString(), PRId); + } + + await this.handlePaymentCancellation(paymentData._id.toString(), session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentNotSuccessful, paymentData._id.toString(), PRId); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + /******************************/ + private async findPaymentByAuthority(authority: string, session: ClientSession) { + const paymentData = await this.PRPaymentRepo.model.findOne({ authority }).session(session); + if (!paymentData) throw new BadRequestError(PaymentMessage.PaymentNotFound); + return paymentData; + } + /******************************/ + + private async findProductRequestByPaymentId(paymentId: string, session: ClientSession) { + const productRequest = await this.productRequestRepo.model.findOne({ payment: paymentId }).session(session); + if (!productRequest) throw new BadRequestError(ProductRequestMessage.NotFound); + return productRequest; + } + /******************************/ + + private async handlePaymentCancellation(paymentId: string, session: ClientSession) { + await this.PRPaymentRepo.model.findByIdAndUpdate(paymentId, { paymentStatus: PaymentStatus.Cancelled }, { session }); + await session.commitTransaction(); + } + /******************************/ + + private async handleSuccessPayment(paymentId: string, refId: string, session: ClientSession) { + await this.PRPaymentRepo.model.findByIdAndUpdate( + paymentId, + { paymentStatus: PaymentStatus.Completed, transaction_id: refId }, + { session }, + ); + await session.commitTransaction(); + } + /******************************/ + + private async verifyPaymentWithGateway( + gateway: GatewayProvider, + authority: string, + amount: number, + ): Promise<{ success: boolean; refId?: string }> { + const result = await this.paymentGateway.verifyPayment(gateway, { authority, amount }); + const success = result.code === 100 || result.code === 101; + return { success, refId: result.ref_id }; + } + + /******************************/ + //method to return response for template rendering + private buildPaymentResponse(status: string, message: string, paymentId: string, request_id: string) { + return { + status, + message, + panel_url: process.env.SELLER_URL, + payment_id: paymentId, + request_id, + }; + } +} diff --git a/src/modules/payment/providers/payment.service.ts b/src/modules/payment/providers/payment.service.ts new file mode 100644 index 0000000..a5d0ec3 --- /dev/null +++ b/src/modules/payment/providers/payment.service.ts @@ -0,0 +1,392 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, Types, isValidObjectId, startSession } from "mongoose"; + +import { CartMessage, PaymentMessage } from "../../../common/enums/message.enum"; +import { OrderItemsStatus, OrdersStatus, PaymentStatus } from "../../../common/enums/order.enum"; +import { GatewayProvider } from "../../../common/enums/payment.enum"; +import { PaymentsQueries } from "../../../common/types/query.type"; +import { BadRequestError } from "../../../core/app/app.errors"; +import { Logger } from "../../../core/logging/logger"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { SMS } from "../../../utils/sms.service"; +import { CartRepository, CartShipItemRepo } from "../../cart/cart.repository"; +import { CartService } from "../../cart/cart.service"; +import { CartDTO } from "../../cart/DTO/cart.dto"; +import { ICartShipmentItem } from "../../cart/models/Abstraction/ICartShipmentItem"; +import { PaymentGateway } from "../../IPG/PaymentGateway"; +import { NotificationService } from "../../notification/notification.service"; +import { IOrderItem } from "../../order/models/Abstraction/IOrderItem"; +import { OrderItemRepo } from "../../order/order.repository"; +import { OrderService } from "../../order/order.service"; +import { PricingRepository } from "../../pricing/pricing.repository"; +import { ProductService } from "../../product/providers/product.service"; +import { ShopRepo } from "../../shop/shop.repository"; +import { IUser } from "../../user/models/Abstraction/IUser"; +import { WalletService } from "../../wallet/wallet.service"; +import { CartCheckoutDTO } from "../DTO/cartCheckout.dto"; +import { PaymentDTO } from "../DTO/payment.dto"; +import { IPaymentMethod } from "../models/Abstraction/IPaymentMethod"; +import { IPriceDetails } from "../models/Abstraction/IPayments"; +import { CartPaymentRepository, PaymentMethodRepo } from "../payment.repository"; + +@injectable() +class PaymentService { + private logger = new Logger(PaymentService.name); + + @inject(IOCTYPES.CartService) cartService: CartService; + @inject(IOCTYPES.OrderService) orderService: OrderService; + @inject(IOCTYPES.ProductService) productService: ProductService; + @inject(IOCTYPES.CartPaymentRepo) cartPaymentRepo: CartPaymentRepository; + @inject(IOCTYPES.PricingRepo) priceRepo: PricingRepository; + @inject(IOCTYPES.OrderItemRepo) orderItemRepo: OrderItemRepo; + @inject(IOCTYPES.CartRepository) cartRepo: CartRepository; + @inject(IOCTYPES.PaymentGateway) paymentGateway: PaymentGateway; + @inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo; + @inject(IOCTYPES.PaymentMethodRepo) paymentMethodRepo: PaymentMethodRepo; + @inject(IOCTYPES.WalletService) walletService: WalletService; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + + //################################################################## + //################################################################## + + async getAllPaymentsForAdminPanelS(queries: PaymentsQueries) { + const { docs, count } = await this.cartPaymentRepo.getPaymentsForAdminPanel(queries); + const payments = docs.map((doc: any) => PaymentDTO.transformPayment(doc)); + + return { + payments, + count, + }; + } + + async getPaymentInfoS(userId: string) { + const { cart } = await this.cartService.getCartS(userId); + const paymentMethods = await this.paymentMethodRepo.model.find().select("-createdAt -updatedAt"); + + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + const cartShipmentItems = await this.cartShipItemRepo.model.aggregate([ + { + $match: { + cart: new Types.ObjectId(cart._id.toString()), + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $addFields: { + shopName: "$shop.shopName", + }, + }, + { + $project: { + shop: 0, + __v: 0, + }, + }, + ]); + if (!cartShipmentItems.length) throw new BadRequestError(CartMessage.CartShipmentItemsNotFound); + + if (!paymentMethods) throw new BadRequestError(PaymentMessage.PaymentMethodsNotFound); + + const { price } = this.calculateTotalPrice(cart, cartShipmentItems); + return { + cart, + cartShipmentItems, + paymentMethods, + price, + }; + } + //############################## + async getPricingDetails() { + return { + pricing: await this.priceRepo.findAll(), + }; + } + + //############################## + async getPaymentMethods() { + return { + paymentMethods: await this.paymentMethodRepo.model.find({ isActive: true }), + }; + } + //################################################################## + //################################################################## + + async createCartPaymentS(cartCheckoutDto: CartCheckoutDTO, user: IUser) { + const session = await startSession(); + session.startTransaction(); + try { + const { cart } = await this.cartService.getCartS(user._id.toString()); + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + + const cartShipmentItems = await this.cartShipItemRepo.model.find({ cart: cart._id.toString() }); + if (!cartShipmentItems.length) throw new BadRequestError(CartMessage.CartShipmentItemsNotFound); + + const paymentMethod = await this.paymentMethodRepo.findById(cartCheckoutDto.payment_method_id); + if (!paymentMethod) throw new BadRequestError(PaymentMessage.PaymentMethodNotFound); + + const { price, description } = this.calculateTotalPrice(cart, cartShipmentItems); + + // extracted to handle payment creation + const { payment, paymentData } = await this.handlePaymentCreation(paymentMethod, price, user, description, session); + + // create order and order items + const { order, orderItems } = await this.orderService.createOrder(user, payment, cartShipmentItems, session); + + // clear cart and adjust stock + await this.finalizeOrder(user, cartShipmentItems, session); + + await session.commitTransaction(); + await session.endSession(); + return { paymentData, order, orderItems }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + this.logger.warn(error); + throw error; + // throw new InternalError(["Order processing failed, please try again."]); + } + } + + //################################################################## + //################################################################## + //verify callback of cart payment + async verifyCartPayment(gateway: GatewayProvider, verifyCallbackData: { authority: string; status: string }) { + const { status, authority } = verifyCallbackData; + const session = await startSession(); + session.startTransaction(); + try { + const paymentData = await this.cartPaymentRepo.model.findOne({ authority }).session(session); + + if (!paymentData) throw new BadRequestError(PaymentMessage.PaymentNotFound); + + const order = await this.orderService.getOrderByPaymentId(paymentData._id.toString(), session); + + if (!order) throw new BadRequestError(PaymentMessage.PaymentNotFound); + const user = order.user as unknown as IUser; + + const orderItems = await this.orderService.getOrderItemByOrderId(order._id, session); + + // Handle unsuccessful payment status + if (status !== "OK") { + await this.handleFailedPayment(paymentData._id.toString(), order._id, orderItems, session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentNotSuccessful, paymentData._id.toString(), order._id); + } + + // Verify payment through gateway + const data = await this.paymentGateway.verifyPayment(gateway, { authority, amount: paymentData.totalPrice }); + if (data.code === 100 || data.code === 101) { + await this.handleSellerNotify(order._id, user, session); + await this.handleSuccessfulPayment(paymentData._id.toString(), order._id, data.ref_id, session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentSuccessful, paymentData._id.toString(), order._id); + } + + // Handle verification failure + await this.handleFailedPayment(paymentData._id.toString(), order._id, orderItems, session); + return this.buildPaymentResponse(status, PaymentMessage.PaymentNotSuccessful, paymentData._id.toString(), order._id); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //################################################################## + //################################################################## + //retry payment + async retryPayment(paymentId: string, user: IUser) { + if (!isValidObjectId(paymentId)) throw new BadRequestError(PaymentMessage.PaymentNotFound); + + const paymentData = await this.cartPaymentRepo.findById(paymentId); + if (!paymentData) throw new BadRequestError(PaymentMessage.PaymentNotFound); + + if (user._id.toString() !== paymentData.user_id.toString()) throw new BadRequestError(PaymentMessage.PaymentNotBelongToUser); + + const paymentMethod = await this.paymentMethodRepo.model.findById(paymentData.payment_method.toString()).lean(); + if (!paymentMethod) throw new BadRequestError(PaymentMessage.PaymentMethodNotFound); + + const data = await this.paymentGateway.retryPayment(paymentMethod.provider as GatewayProvider, paymentData.authority); + return { + ...data, + }; + } + + //################################################################## + //################################################################## + // Helper method to calculate the total price of cart items + private calculateTotalPrice(cart: CartDTO, cartShipItem: ICartShipmentItem[]) { + const totalShippingCost = cartShipItem.reduce( + (sum, item) => sum + item.shipmentItems.reduce((subSum, shipmentItem) => subSum + shipmentItem.shipmentCost, 0), + 0, + ); + + // const totalCouponDiscount = cartShipItem.reduce((sum, item) => sum + item.totalCouponDiscount, 0); + // const totalShippingCost = cartShipItem.reduce((sum, item) => sum + item.shipmentCost, 0); + + const price = { + shipping_cost: totalShippingCost, + // process_cost: totalShippingCost, + process_cost: 0, + cart_retail_price: cart.retail_price, + // cart_payable_price: cart.payable_price - cart.coupon_discount, + cart_payable_price: cart.payable_price, + total_discount: cart.total_discount, + coupon_discount: cart.coupon_discount, + // total_payable_price: cart.payable_price + totalShippingCost * 2, + total_payable_price: cart.isWholeSale ? cart.retail_price + totalShippingCost : cart.payable_price + totalShippingCost, + }; + + const items = cart.items; + let description = "خرید کالا:"; + items.forEach((item) => { + description += `${item.product.model} (${item.quantity}), `; + }); + description = description.slice(0, -2); + return { price, description }; + } + //################################################################## + //################################################################## + private async handleFailedPayment(paymentId: string, orderId: number, orderItems: IOrderItem[], session: ClientSession) { + await this.updatePaymentStatusAndReference(paymentId, null, PaymentStatus.Cancelled, session); + + await this.orderItemRepo.model.updateMany({ order: orderId }, { status: OrderItemsStatus.cancelled_system }, { session }); + + //increase the product stock because of failed payment + const itemsToUpdateStock = orderItems.flatMap((sellerItem) => + sellerItem.shipmentItems.map((shipmentItem) => ({ + variantId: shipmentItem.variant.toString(), + quantity: shipmentItem.quantity, + })), + ); + + await this.productService.updateStock(itemsToUpdateStock, "increase", session); + + await this.orderService.updateOrderStatus(orderId, OrdersStatus.cancelled_system, session); + + await session.commitTransaction(); + } + //################################################################## + //################################################################## + private async handleSuccessfulPayment(paymentId: string, orderId: number, refId: number, session: ClientSession) { + await this.updatePaymentStatusAndReference(paymentId, refId, PaymentStatus.Completed, session); + + await this.orderService.updateOrderStatus(orderId, OrdersStatus.process_by_seller, session); + + await session.commitTransaction(); + } + //################################################################## + //################################################################## + private async handleSellerNotify(orderId: number, user: IUser, session: ClientSession) { + const orderItems = await this.orderItemRepo.model.find({ order: orderId }).lean(); + + await SMS.sendOrderCreatedSms(user.phoneNumber, user.fullName, orderId); + + // group items by seller and calculate total price for each seller + const shopTotalMap: Map = new Map(); + + for (const item of orderItems) { + const shopId = item.shop.toString(); + // const totalPriceForItem = item.shipmentItems.reduce((acc, shippingItem) => { + // const totalCost = shippingItem.selling_price + shippingItem.shipmentCost; + // return totalCost + acc; + // }, 0); + const totalPriceForItem = item.totalPaymentPrice; + + if (shopTotalMap.has(shopId)) { + shopTotalMap.set(shopId, shopTotalMap.get(shopId)! + totalPriceForItem); + } else { + shopTotalMap.set(shopId, totalPriceForItem); + } + } + + // credit each sellers wallet with their corresponding total price + for (const [shopId, totalPrice] of shopTotalMap.entries()) { + const shop = await this.shopRepo.findById(shopId); + await this.notificationService.notifyNewOrder(shop!.owner.toString(), orderId, totalPrice, session); + } + } + + //################################################################## + //################################################################## + //method to return response for template rendering + private buildPaymentResponse(status: string, message: string, paymentId: string, orderId: number | string) { + return { + status, + message, + store_url: process.env.STORE_URL, + payment_id: paymentId, + order_id: orderId, + }; + } + //################################################################## + //################################################################## + private async handlePaymentCreation(paymentMethod: IPaymentMethod, price: any, user: IUser, description: string, session: ClientSession) { + const paymentData = await this.paymentGateway.requestPayment(paymentMethod.provider as GatewayProvider, { + amount: price.total_payable_price, + description, + email: user.email, + mobile: user.phoneNumber, + callbackPath: "cart", + }); + + if (!paymentData) throw new BadRequestError(PaymentMessage.PaymentRequestFailed); + + const priceDetails: Partial = { + shipping_cost: price.shipping_cost, + process_cost: price.process_cost, + total_retail_price: price.cart_retail_price, + total_discount: price.total_discount, + coupon_discount: price.coupon_discount, + total_payable_price: price.total_payable_price, + }; + + const payment = await this.cartPaymentRepo.model.create( + [ + { + user_id: user._id.toString(), + authority: paymentData.authority, + payment_method: paymentMethod._id, + priceDetails, + totalPrice: priceDetails.total_payable_price, + }, + ], + { session }, + ); + + return { payment: payment[0], paymentData }; + } + //################################################################## + //################################################################## + private async finalizeOrder(user: IUser, cartShipmentItems: ICartShipmentItem[], session: ClientSession) { + // empty the cart and cartShipment after successful order creation + await this.cartService.clearCartAndShipmentItems(user._id.toString(), session); + + // reduce stock for ordered items + const itemsToUpdateStock = cartShipmentItems.flatMap((cartItem) => + cartItem.shipmentItems.map((shipmentItem) => ({ + variantId: shipmentItem.variant.toString(), + quantity: shipmentItem.quantity, + })), + ); + + await this.productService.updateStock(itemsToUpdateStock, "decrease", session); + } + //################################################################## + //################################################################## + private async updatePaymentStatusAndReference(paymentId: string, refId: number | null, status: PaymentStatus, session: ClientSession) { + return await this.cartPaymentRepo.model.findByIdAndUpdate(paymentId, { paymentStatus: status, transaction_id: refId }, { session }); + } +} + +export { PaymentService }; diff --git a/src/modules/ping.ts b/src/modules/ping.ts new file mode 100644 index 0000000..f1be5c0 --- /dev/null +++ b/src/modules/ping.ts @@ -0,0 +1,12 @@ +import { BaseHttpController, controller, httpGet } from "inversify-express-utils"; + +import { ApiTags } from "../common/decorator/swggerDocs"; + +@ApiTags("ping") +@controller("/ping") +export class PingController extends BaseHttpController { + @httpGet("") + public async pong() { + return this.json({ result: "pong", status: 200 }); + } +} diff --git a/src/modules/pricing/DTO/createPricing.dto.ts b/src/modules/pricing/DTO/createPricing.dto.ts new file mode 100644 index 0000000..d2ba0cc --- /dev/null +++ b/src/modules/pricing/DTO/createPricing.dto.ts @@ -0,0 +1,24 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsInt, IsNotEmpty, Min } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { PricingTypeEnum } from "../models/Abstraction/IPricing"; + +export class CreatePricingDTO { + @Expose() + @IsNotEmpty() + @IsEnum(PricingTypeEnum) + @ApiProperty({ + type: "string", + description: `the price type ==> ${PricingTypeEnum.EXPERT_REVIEWS_FEE} | ${PricingTypeEnum.INSERT_FEE} | ${PricingTypeEnum.PHOTOGRAPHY_FEE} | ${PricingTypeEnum.UNBOXING_VIDEO_FEE}`, + example: `${PricingTypeEnum.INSERT_FEE}`, + }) + type: PricingTypeEnum; + + @Expose() + @IsNotEmpty() + @IsInt() + @Min(10000) + @ApiProperty({ type: "number", example: 10000, description: "the price amount" }) + price: number; +} diff --git a/src/modules/pricing/DTO/updatePricing.dto.ts b/src/modules/pricing/DTO/updatePricing.dto.ts new file mode 100644 index 0000000..75ce3da --- /dev/null +++ b/src/modules/pricing/DTO/updatePricing.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePricingDTO } from "./createPricing.dto"; + +export class UpdatePricingDTO extends PartialType(CreatePricingDTO) {} diff --git a/src/modules/pricing/models/Abstraction/IPricing.ts b/src/modules/pricing/models/Abstraction/IPricing.ts new file mode 100644 index 0000000..7f7d98a --- /dev/null +++ b/src/modules/pricing/models/Abstraction/IPricing.ts @@ -0,0 +1,11 @@ +export interface IPricing { + type: PricingTypeEnum; + price: number; +} + +export enum PricingTypeEnum { + INSERT_FEE = "insertionFee", + PHOTOGRAPHY_FEE = "photographyFee", + UNBOXING_VIDEO_FEE = "unboxingVideoFee", + EXPERT_REVIEWS_FEE = "expertReviewsFee", +} diff --git a/src/modules/pricing/models/pricing.model.ts b/src/modules/pricing/models/pricing.model.ts new file mode 100644 index 0000000..0816d61 --- /dev/null +++ b/src/modules/pricing/models/pricing.model.ts @@ -0,0 +1,15 @@ +import { Schema, model } from "mongoose"; + +import { IPricing, PricingTypeEnum } from "./Abstraction/IPricing"; + +const PricingSchema = new Schema( + { + type: { type: String, enum: PricingTypeEnum, required: true, unique: true }, + price: { type: Number, required: true, min: 1000 }, + }, + { timestamps: true, toJSON: { virtuals: true }, id: false }, +); + +const PricingModel = model("Pricing", PricingSchema); + +export { PricingModel }; diff --git a/src/modules/pricing/pricing.repository.ts b/src/modules/pricing/pricing.repository.ts new file mode 100644 index 0000000..2190a43 --- /dev/null +++ b/src/modules/pricing/pricing.repository.ts @@ -0,0 +1,14 @@ +import { IPricing } from "./models/Abstraction/IPricing"; +import { PricingModel } from "./models/pricing.model"; +import { BaseRepository } from "../../common/base/repository"; + +class PricingRepository extends BaseRepository { + constructor() { + super(PricingModel); + } +} +function createPricingRepo(): PricingRepository { + return new PricingRepository(); +} + +export { PricingRepository, createPricingRepo }; diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts new file mode 100644 index 0000000..1aedcf1 --- /dev/null +++ b/src/modules/pricing/pricing.service.ts @@ -0,0 +1,68 @@ +import { inject, injectable } from "inversify"; + +import { CreatePricingDTO } from "./DTO/createPricing.dto"; +import { UpdatePricingDTO } from "./DTO/updatePricing.dto"; +import { PricingRepository } from "./pricing.repository"; +import { CommonMessage, PricingMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class PricingService { + @inject(IOCTYPES.PricingRepo) pricingRepo: PricingRepository; + + //####################################################### + async createPricing(createDto: CreatePricingDTO) { + const existType = await this.pricingRepo.model.findOne({ type: createDto.type }); + if (existType) throw new BadRequestError(PricingMessage.TypeIsExist); + + const pricing = await this.pricingRepo.model.create(createDto); + + return { + pricing, + }; + } + + //####################################################### + + async deletePricing(id: string) { + const pricing = await this.pricingRepo.model.findByIdAndDelete(id); + if (!pricing) throw new BadRequestError(PricingMessage.NotFound); + + return { + message: CommonMessage.Deleted, + pricing, + }; + } + + //####################################################### + async updatePricing(id: string, updateDto: UpdatePricingDTO) { + const existType = await this.pricingRepo.model.findOne({ type: updateDto.type, _id: { $ne: id } }); + if (existType) throw new BadRequestError(PricingMessage.TypeIsExist); + + const pricing = await this.pricingRepo.model.findByIdAndUpdate(id, updateDto, { new: true }); + if (pricing) throw new BadRequestError(PricingMessage.NotFound); + return { + pricing, + }; + } + + //####################################################### + + async getPricingById(id: string) { + const pricing = await this.pricingRepo.model.findById(id); + if (!pricing) throw new BadRequestError(PricingMessage.NotFound); + + return { + pricing, + }; + } + + async getAllPricing() { + const pricing = await this.pricingRepo.model.find(); + + return { + pricing, + }; + } +} diff --git a/src/modules/product/DTO/CreateProduct.dto.ts b/src/modules/product/DTO/CreateProduct.dto.ts new file mode 100644 index 0000000..109c6c2 --- /dev/null +++ b/src/modules/product/DTO/CreateProduct.dto.ts @@ -0,0 +1,212 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { AttributeMessage } from "../../../common/enums/message.enum"; +import { ProductSource } from "../../../common/enums/product.enum"; +import { BrandModel } from "../../brand/models/brand.model"; +import { CategoryModel } from "../../category/models/category.model"; +import { CategoryAttributeModel } from "../../category/models/CategoryAttribute.model"; +import { ProductModel } from "../models/product.model"; + +// export class SpecificationsDTO { +// @Expose() +// @IsNotEmpty() +// @IsString() +// title: string; + +// @Expose() +// @IsNotEmpty() +// @IsArray() +// @ArrayMinSize(1) +// @ValidateNested({ each: true }) +// @Type(() => SpecificationsAttr) +// attributes: SpecificationsAttr[]; +// } + +// export class SpecificationsAttr { +// @Expose() +// @IsNotEmpty() +// @IsString() +// title: string; + +// @Expose() +// @IsNotEmpty() +// @IsArray() +// values: string[]; +// } + +//===========> +export class ProductStepOneDTO { + @Expose() + @IsNotEmpty() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "category id of the product", example: "66ab8a37d09606beb22eb0d1" }) + category!: string; // Category ID + + @Expose() + @IsNotEmpty() + @IsValidId(BrandModel) + @ApiProperty({ type: "string", description: "brand id of the product", example: "66ab8a37d09606beb22eb0d1" }) + brand!: string; // Brand ID + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the model of the product", + example: "Galaxy S24 Ultra", + }) + model!: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the title in farsi", + example: "گوشی موبایل سامسونگ مدل Galaxy S24 Ultra دو سیم کارت ظرفیت 256 گیگابایت و رم 12 گیگابایت - ویتنام ", + }) + title_fa: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the title in english", + example: "Samsung Galaxy S24 Ultra Dual SIM 256GB And 12GB RAM Mobile Phone - Vietnam", + }) + title_en?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the seo title of the product", + example: "گوشی موبایل سامسونگ مدل Galaxy S24 Ultra دو سیم کارت ظرفیت 256 گیگابایت و رم 12 گیگابایت - ویتنام ", + }) + seoTitle?: string; + + @Expose() + @IsNotEmpty() + @IsEnum(ProductSource) + @ApiProperty({ type: "string", description: "source of the product", example: "local" }) + source: ProductSource; + + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "determine if product is fake or not", example: "false" }) + isFake: boolean; +} + +export class Attributes { + @Expose() + @IsNotEmpty() + @IsValidId(CategoryAttributeModel, { message: AttributeMessage.AttributeIdIsIncorrect }) + id: number; + + @Expose() + @IsNotEmpty() + @IsArray() + values: number[]; +} +export class ProductStepTwoDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ProductModel) + @ApiProperty({ type: "number", description: "the id of product ", example: "100002" }) + productId: number; + + @Expose() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => Attributes) + @ApiProperty({ + type: "Array", + description: "send attribute id to create a specification of product", + example: [{ id: 2, values: [874] }], + }) + attributes: Attributes[]; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of the product", + example: + "همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند و همواره رقیبان سرسختی برای برند‌های دیگر فعال در این عرصه بودند و این بار نوبت به قدرت‌نمایی سامسونگ Galaxy S24 Ultra رسیده است.", + }) + description: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "metaDescription of the product", + example: + "همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند و همواره رقیبان سرسختی برای برند‌های دیگر فعال در این عرصه بودند و این بار نوبت به قدرت‌نمایی سامسونگ Galaxy S24 Ultra رسیده است.", + }) + metaDescription: string; + + @Expose() + @IsNotEmpty() + @IsArray() + @ApiProperty({ type: "Array", description: "the array of product tags", example: ["mobile", "samsung"] }) + tags: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ + type: "Array", + description: "advantages about product quality and features", + example: ["شارژدهی بالا", "دوربین عالی"], + }) + advantages: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ApiProperty({ + type: "Array", + description: "disAdvantages about product quality and features", + example: ["قیمت بالا", "بدون جک هدفون"], + }) + disAdvantages: string[]; +} + +export class ProductFinalStepDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ProductModel) + @ApiProperty({ type: "number", description: "the id of product ", example: "100002" }) + productId: number; + + @Expose() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "addresses of product images", example: ["https://cdnUrl.com", "https://cdnUrl.com"] }) + imagesList: string[]; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "url address of product cover images", example: "https://cdnUrl.com" }) + coverImage: string; +} diff --git a/src/modules/product/DTO/RequestProduct.dto.ts b/src/modules/product/DTO/RequestProduct.dto.ts new file mode 100644 index 0000000..cf2afd7 --- /dev/null +++ b/src/modules/product/DTO/RequestProduct.dto.ts @@ -0,0 +1,155 @@ +import { Expose } from "class-transformer"; +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductSource } from "../../../common/enums/product.enum"; +import { BrandModel } from "../../brand/models/brand.model"; +import { CategoryModel } from "../../category/models/category.model"; +// import { ProductRequestModel } from "../models/productRequest.model"; + +export class RequestProductDTO { + // @Expose() + // @IsNotEmpty() + // @IsValidId(ProductRequestModel) + // @ApiProperty({ type: "string", description: "payment id of the successful payment", example: "88ab8a37d09606beb22eb0d1" }) + // payment: string; + + @Expose() + @IsNotEmpty() + @IsValidId(CategoryModel) + @ApiProperty({ type: "string", description: "category id of the product", example: "66ab8a37d09606beb22eb2d1" }) + category: string; + + @Expose() + @IsNotEmpty() + @IsValidId(BrandModel) + @ApiProperty({ type: "string", description: "brand id of the product", example: "66ab8a37d09606beb22eb0d1" }) + brand: string; + + @Expose() + @IsNotEmpty() + @IsEnum(ProductSource) + @ApiProperty({ type: "string", description: "source of the product", example: "local" }) + source: ProductSource; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "name of product", example: "iphone13" }) + productName: string; + + // @Expose() + // @IsNotEmpty() + // @IsString() + // @ApiProperty({ type: "string", description: "type of product", example: "test" }) + // productType: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of the product", + example: + "همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند و همواره رقیبان سرسختی برای برند‌های دیگر فعال در این عرصه بودند و این بار نوبت به قدرت‌نمایی سامسونگ Galaxy S24 Ultra رسیده است.", + }) + description?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + type: "Array", + description: "advantages about product quality and features", + example: ["شارژدهی بالا", "دوربین عالی"], + }) + advantages: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + type: "Array", + description: "disAdvantages about product quality and features", + example: ["قیمت بالا", "بدون جک هدفون"], + }) + disAdvantages: string[]; + + //dimensions + //############################# + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the weight of product package in gram", example: 250 }) + package_weight: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the height of product package in cm", example: 25 }) + package_height: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the length of product package in cm", example: 45 }) + package_length: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the width of product package in cm", example: 10 }) + package_width: number; + + //############################# + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify if request to take photo from admin", example: true }) + requestPhotography: boolean; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the requested photos count", example: 5 }) + requestPhotosCount: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify if request to take unboxing video from admin", example: true }) + unboxingVideo: boolean; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify if request to review of product from admin", example: true }) + expertReview: boolean; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "url address of product cover images", example: "https://cdnUrl.com" }) + coverImage: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "addresses of product images", example: ["https://cdnUrl.com", "https://cdnUrl.com"] }) + imagesList?: string[]; +} diff --git a/src/modules/product/DTO/addProductAd.dto.ts b/src/modules/product/DTO/addProductAd.dto.ts new file mode 100644 index 0000000..e400e1e --- /dev/null +++ b/src/modules/product/DTO/addProductAd.dto.ts @@ -0,0 +1,66 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId, IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { AdType } from "../models/Abstraction/IProductAd"; +import { ProductVariantModel } from "../models/productVariant.model"; + +export class ProductAdDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product variant", example: "66ba2461d64d63a2494df185" }) + variantId: string; + + @Expose() + @IsNotEmpty() + @IsEnum(AdType) + @ApiProperty({ type: "string", description: "The type of advertisement (click or daily)", example: "click" }) + type: AdType; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Cost per click or per day, depending on the ad type", example: 50 }) + cost: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ + type: "number", + description: "max allowed clicks for click-based ads", + example: 100, + required: false, + }) + maxClicks?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @IsValidPersianDate() + @ApiProperty({ + type: "string", + description: "start date for daily-based ads ", + example: "1403/08/01 for => daily based", + required: false, + }) + startDate?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @IsValidPersianDate() + @ApiProperty({ + type: "string", + description: "End date for daily-based ads => daily based ", + example: "1403/08/09", + required: false, + }) + endDate?: string; +} diff --git a/src/modules/product/DTO/addVariant.dto.ts b/src/modules/product/DTO/addVariant.dto.ts new file mode 100644 index 0000000..a94abbe --- /dev/null +++ b/src/modules/product/DTO/addVariant.dto.ts @@ -0,0 +1,106 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsOptional, Min } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ColorModel } from "../../category/models/color.model"; +import { MeterageModel } from "../../category/models/meterage.model"; +import { SizeModel } from "../../category/models/size.model"; +import { WarrantyModel } from "../../warranty/models/warranty.model"; +// import { ShipmentModel } from "../../../models/shipment.model"; + +export class AddVariantDTO { + // @Expose() + // @IsNotEmpty() + // @IsValidId(WarrantyModel) + // @ApiProperty({ type: "number", description: "product id", example: 100002 }) + // productId: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(ColorModel) + @ApiProperty({ type: "number", description: "color id ==> one of color,size or meterage should send", example: 1 }) + colorId?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(SizeModel) + @ApiProperty({ type: "number", description: "size id ==> one of color,size or meterage should send", example: 5 }) + sizeId?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidId(MeterageModel) + @ApiProperty({ type: "number", description: "meterage id ==> one of color,size or meterage should send", example: 5 }) + meterageId?: number; + + @Expose() + @IsNotEmpty() + @IsValidId(WarrantyModel) + @ApiProperty({ type: "number", description: "warranty id of the product", example: 102 }) + warrantyId: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "maximum number of product can purchase in one order", example: 2 }) + order_limit: number; + + // + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "special code of product for seller only", example: "FPHK10" }) + sellerSpecialCode?: string; + + @Expose() + @IsNotEmpty() + @Min(10000, { message: "حداقل قیمت برای کالا ۱۰۰۰۰ تومان می باشد " }) + @IsNumber() + @ApiProperty({ type: "number", description: "the price of product in toman", example: 5432510 }) + retail_price: number; + + //Dimensions + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the weight of product package in gram", example: 250 }) + package_weight: number; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the height of product package in cm", example: 25 }) + package_height: number; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the length of product package in cm", example: 45 }) + package_length: number; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the width of product package in cm", example: 10 }) + package_width: number; + + // @Expose() + // @IsNotEmpty() + // @IsArray() + // @IsNumber({}, { each: true }) + // // @IsValidId(ShipmentModel) + // @ApiProperty({ type: "Array", description: "the array of shipment provider", example: [101, 102] }) + // shipmentsId: number[]; + // + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the time take to ship product quantity of product in day", example: 10 }) + postingTime: number; + + // + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the stock quantity of product", example: 10 }) + stock: number; +} diff --git a/src/modules/product/DTO/addwishlist.dto.ts b/src/modules/product/DTO/addwishlist.dto.ts new file mode 100644 index 0000000..2561049 --- /dev/null +++ b/src/modules/product/DTO/addwishlist.dto.ts @@ -0,0 +1,23 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductVariantModel } from "../models/productVariant.model"; + +export class AddWishlistDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product", example: "66eadea49f6f5524f31f5b6f" }) + variantId: string; +} +export class RemoveWishlistDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product", example: "66eadea49f6f5524f31f5b6f" }) + variantId: string; +} diff --git a/src/modules/product/DTO/createQuestion.dto.ts b/src/modules/product/DTO/createQuestion.dto.ts new file mode 100644 index 0000000..f09d4be --- /dev/null +++ b/src/modules/product/DTO/createQuestion.dto.ts @@ -0,0 +1,89 @@ +import { Expose, Type } from "class-transformer"; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, Length, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateQuestionDTO { + @Expose() + @IsNotEmpty() + @IsString() + @Length(8) + @ApiProperty({ type: "string", description: "content of question", example: "جنسش جیه؟" }) + content: string; +} + +class AnswerContentDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "Content of the answer", + example: "جنس این محصول چرم است", + }) + content: string; +} + +export class AnswerQuestionDTO { + @Expose() + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AnswerContentDTO) + @ApiProperty({ + type: "Array", + description: "List of answers", + example: [{ content: "جنس این محصول چرم است" }], + required: false, // Optional field + }) + answers?: AnswerContentDTO[]; +} + +export class CreateAnswerQuestionDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "Content of the answer", + example: "جنس این محصول چرم است", + }) + content: string; +} + +export class CreateCommentDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of review", example: "عالی" }) + title: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "advantage", example: ["عالی", "پر سرعت", "ارزان"] }) + advantage: string[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ type: "Array", description: "disAdvantage", example: ["بد", "کند", "گران"] }) + disAdvantage: string[]; + + @Expose() + @IsNotEmpty() + @IsString() + @Length(8) + @ApiProperty({ type: "string", description: "content of comment", example: "عالی بود" }) + content: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "rate of product", example: 5 }) + rate: number; +} diff --git a/src/modules/product/DTO/createReport.dto.ts b/src/modules/product/DTO/createReport.dto.ts new file mode 100644 index 0000000..94aeec9 --- /dev/null +++ b/src/modules/product/DTO/createReport.dto.ts @@ -0,0 +1,47 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ReportQuestionModel } from "../models/reportQuestion.model"; + +class AnswerDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ReportQuestionModel) + questionId: number; +} + +export class CreateReportDTO { + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => AnswerDTO) + @ApiProperty({ + type: "Array", + description: "specify the answer of report questions", + example: [ + { + questionId: 1, + }, + { + questionId: 4, + }, + ], + }) + answers: AnswerDTO[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of the report ", + example: " کالا فیک می‌باشد اما در توضیحات اصلی نوشته شده است", + }) + description: string; +} diff --git a/src/modules/product/DTO/createReportQuestion.dto.ts b/src/modules/product/DTO/createReportQuestion.dto.ts new file mode 100644 index 0000000..d01bfde --- /dev/null +++ b/src/modules/product/DTO/createReportQuestion.dto.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsString, MinLength } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateReportQuestionDTO { + @IsNotEmpty() + @IsString() + @MinLength(3) + @ApiProperty({ type: "string", description: "Title of the question", example: "نام کالا نادرست است" }) + title: string; + + // @IsNotEmpty() + // @IsString() + // @MinLength(3) + // @ApiProperty({ type: "string", description: "placeholder of the question", example: "" }) + // placeholder: string; +} diff --git a/src/modules/product/DTO/observer.dto.ts b/src/modules/product/DTO/observer.dto.ts new file mode 100644 index 0000000..e65623e --- /dev/null +++ b/src/modules/product/DTO/observer.dto.ts @@ -0,0 +1,23 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductVariantModel } from "../models/productVariant.model"; + +export class AddObserverDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product", example: "66eadea49f6f5524f31f5b6f" }) + variantId: string; +} +export class RemoveObserverDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product", example: "66eadea49f6f5524f31f5b6f" }) + variantId: string; +} diff --git a/src/modules/product/DTO/product.dto.ts b/src/modules/product/DTO/product.dto.ts new file mode 100644 index 0000000..1526bb6 --- /dev/null +++ b/src/modules/product/DTO/product.dto.ts @@ -0,0 +1,438 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; +import { IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { ProductStatus } from "../../../common/enums/product.enum"; +import { BrandDTO } from "../../brand/DTO/brand.dto"; +import { CategoryTreeDTO, ColorDTO, Size_MeterageDTO } from "../../category/DTO/category.dto"; +import { ShipmentMethodDTO } from "../../shipment/DTO/shipment.dto"; +import { ShopDTO } from "../../shop/DTO/shop.dto"; +import { WarrantyDTO } from "../../warranty/DTO/warranty.dto"; +import { IncredibleOffers } from "../models/Abstraction/IncredibleOffers"; +import { IProduct } from "../models/Abstraction/IProduct"; +import { IProductVariant } from "../models/Abstraction/IProductVariant"; +//********************************* +class SpecificationsDTO { + @Expose() + title: string; + + @Expose() + values: string[]; +} +//********************************* +class ImagesUrlDTO { + @Expose() + cover: string; + + @Expose() + list: string[]; +} + +//********************************* + +class ProductPrice { + @Expose() + order_limit: number; + @Expose() + retailPrice: number; + + @Expose() + selling_price: number; + + // @Expose() + // shipping_fee: number; + //specialSale + @Expose() + is_specialSale: boolean; + + @Expose() + discount_percent: number; + + @Expose() + specialSale_order_limit: number; + + @Expose() + specialSale_quantity: number; + + @Expose() + specialSale_endDate: string; +} + +//********************************* + +class WholeSaleRangeDTO { + @Expose() + from: number; + + @Expose() + to: number; + + @Expose() + price: number; +} +//********************************* + +class SaleFormatDTO { + // @Expose() + // isWholeSale: boolean; + + @Expose() + @Type(() => WholeSaleRangeDTO) + wholeSale: WholeSaleRangeDTO[]; +} + +//################################# + +export class ProductDetailVariantDTO { + @Expose() + _id: string; + + @Expose() + market_status: string; + + @Expose() + @Type(() => ProductPrice) + price: ProductPrice; + + @Expose() + stock: number; + + @Expose() + postingTime: number; + + @Expose() + rate: number; + + @Expose() + isFreeShip: boolean; + + @Expose() + isWholeSale: boolean; + + @Expose() + @Type(() => ShopDTO) + shop: ShopDTO; + + @Expose() + @Type(() => SaleFormatDTO) + saleFormat: SaleFormatDTO; + + @Expose() + @Type(() => ShipmentMethodDTO) + shipmentMethod: ShipmentMethodDTO[]; + + @Expose() + @Type(() => WarrantyDTO) + warranty: WarrantyDTO; + + @Expose() + @Type(() => ColorDTO) + color?: ColorDTO; + + @Expose() + @Type(() => Size_MeterageDTO) + size?: Size_MeterageDTO; + + @Expose() + @Type(() => Size_MeterageDTO) + meterage?: Size_MeterageDTO; +} + +//################################# +//################################# + +export class SellerPanelProductDTO { + @Expose() + _id: number; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + seoTitle: string; + + @Expose() + seoDescription: string; + + @Expose() + description: string; + + @Expose() + metaDescription: string; + + @Expose() + shopName: string; + + @Expose() + shopCode: string; + + @Expose() + category_title_fa: string; + + @Expose() + category_id: string; + + @Expose() + brand_title_fa: string; + + @Expose() + status: ProductStatus; + + @Expose() + adminComments: string; + + @Expose() + step: number; + + @Expose() + isFake: boolean; + + @Expose() + @Type(() => ImagesUrlDTO) + imagesUrl: ImagesUrlDTO; + + @Expose() + variantCount: number; + + @Expose() + @Type(() => ProductDetailVariantDTO) + default_variant: ProductDetailVariantDTO; + + @Expose() + popular: boolean; + + @Expose() + incredible: boolean; + + @Expose() + voice: string; + + public static transformProduct(data: IProduct): SellerPanelProductDTO { + const productDTO = plainToInstance(SellerPanelProductDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return productDTO; + } +} + +//################################# +//################################# + +export class ProductVariantDTO { + @Expose() + _id: string; + + @Expose() + product_id: number; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + model: string; + + @Expose() + market_status: string; + + @Expose() + productCode: string; + + @Expose() + category_title_fa: string; + + @Expose() + is_variant_active: boolean; + + @Expose() + coverImage: string; + + @Expose() + @Type(() => ProductPrice) + price: ProductPrice; + + @Expose() + stock: number; + + @Expose() + sellerSpecialCode: string; + + @Expose() + @Type(() => ShipmentMethodDTO) + shipmentMethod: ShipmentMethodDTO[]; + + @Expose() + product_status: string; + + @Expose() + isFreeShip: boolean; + + @Expose() + isWholeSale: boolean; + + @Expose() + @Type(() => SaleFormatDTO) + saleFormat: SaleFormatDTO; + + @Expose() + @Type(() => ColorDTO) + color?: ColorDTO; + + @Expose() + @Type(() => Size_MeterageDTO) + size?: Size_MeterageDTO; + + @Expose() + @Type(() => Size_MeterageDTO) + meterage?: Size_MeterageDTO; + + public static transformProduct(data: IProductVariant): ProductVariantDTO { + const productVariantDTO = plainToInstance(ProductVariantDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return productVariantDTO; + } +} + +// @Transform((value) => new Types.ObjectId(value.obj._id.toString())) +// _id: Types.ObjectId; + +//################################# +//################################# +export class ProductDTO { + @Expose() + _id: number; + + @Expose() + url: string; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + seoTitle: string; + + @Expose() + seoDescription: string; + + model: string; + + @Expose() + source: string; + + @Expose() + description: string; + + @Expose() + metaDescription: string; + + @Expose() + tags: string[]; + + @Expose() + advantages: string[]; + + @Expose() + disAdvantages: string[]; + + @Expose() + @Expose() + @Type(() => ImagesUrlDTO) + imagesUrl: ImagesUrlDTO; + + @Expose() + isFake: string; + + @Expose() + isWished: boolean; + + @Expose() + voice: string; + + @Expose() + @Type(() => SpecificationsDTO) + specifications: SpecificationsDTO[]; + + @Expose() + @Type(() => BrandDTO) + brand: BrandDTO; + + @Expose() + @Type(() => CategoryTreeDTO) + category: CategoryTreeDTO; + + @Expose() + @Type(() => ProductDetailVariantDTO) + default_variant: ProductDetailVariantDTO; + + @Expose() + @Type(() => ProductDetailVariantDTO) + variants: ProductDetailVariantDTO[]; + + // @Expose() + // categoryPath: string; + + public static transformProduct(data: IProduct): ProductDTO { + const productDTO = plainToInstance(ProductDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return productDTO; + } +} + +export class RejectCommentDTO { + @Expose() + @IsString() + @ApiProperty({ type: "string", description: "reject reason", example: "reject for this reason" }) + adminComments: string; +} + +export class IncredibleOffersDTO { + @Expose() + @Type(() => ProductDTO) + product: ProductDTO; + + @Expose() + @Type(() => ProductVariantDTO) + variant: ProductVariantDTO; + + public static transformIncredibleOffers(data: IncredibleOffers): IncredibleOffersDTO { + const incredibleOffersDTO = plainToInstance(IncredibleOffersDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return incredibleOffersDTO; + } +} + +export class ProductListDTO { + @Expose() + _id: number; + + @Expose() + title_fa: string; + + @Expose() + title_en: string; + + @Expose() + seoTitle: string; + + @Expose() + seoDescription: string; +} diff --git a/src/modules/product/DTO/productParam.dto.ts b/src/modules/product/DTO/productParam.dto.ts new file mode 100644 index 0000000..fdd7a30 --- /dev/null +++ b/src/modules/product/DTO/productParam.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; + +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductModel } from "../models/product.model"; +import { ProductVariantModel } from "../models/productVariant.model"; + +export class ProductParamDto { + @Expose() + @IsNotEmpty() + @IsValidId(ProductModel) + id: number; +} + +export class SetFreeShipParamDto extends ProductParamDto { + @Expose() + @IsNotEmpty() + @IsValidId(ProductVariantModel) + variantId: string; +} diff --git a/src/modules/product/DTO/updateProduct.dto.ts b/src/modules/product/DTO/updateProduct.dto.ts new file mode 100644 index 0000000..342e7f0 --- /dev/null +++ b/src/modules/product/DTO/updateProduct.dto.ts @@ -0,0 +1,90 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator"; + +import { ProductFinalStepDTO, ProductStepOneDTO, ProductStepTwoDTO } from "./CreateProduct.dto"; +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ProductModel } from "../models/product.model"; + +export class UpdateProductStepOneDTO extends PartialType(ProductStepOneDTO) { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ProductModel) + @ApiProperty({ type: "number", description: "the product id", example: 100004 }) + productId: number; +} + +export class UpdateProductStepTwoDTO extends PartialType(ProductStepTwoDTO) { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ProductModel) + @ApiProperty({ type: "number", description: "the product id", example: 100004 }) + productId: number; +} + +export class UpdateProductFinalStepDTO extends PartialType(ProductFinalStepDTO) { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ProductModel) + @ApiProperty({ type: "number", description: "the product id", example: 100004 }) + productId: number; +} + +export class AddVoiceToProductDTO { + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the voice url", example: "http://voice.com" }) + voice: string; +} + +export class UpdateProductDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the title in farsi", + example: "گوشی موبایل سامسونگ مدل Galaxy S24 Ultra دو سیم کارت ظرفیت 256 گیگابایت و رم 12 گیگابایت - ویتنام ", + }) + title_fa?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the title in english", + example: "Samsung Galaxy S24 Ultra Dual SIM 256GB And 12GB RAM Mobile Phone - Vietnam", + }) + title_en?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of the product", + example: + "همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند و همواره رقیبان سرسختی برای برند‌های دیگر فعال در این عرصه بودند و این بار نوبت به قدرت‌نمایی سامسونگ Galaxy S24 Ultra رسیده است.", + }) + description?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "metaDescription of the product", + example: + "همواره گوشی‌های هوشمند پرچمدار سامسونگ توانسته‌اند با بهره بردن از مشخصات فنی قدرتمند، توجه هر بیننده‌ای را به خود جلب کنند و همواره رقیبان سرسختی برای برند‌های دیگر فعال در این عرصه بودند و این بار نوبت به قدرت‌نمایی سامسونگ Galaxy S24 Ultra رسیده است.", + }) + metaDescription?: string; +} diff --git a/src/modules/product/DTO/updateVariant.dto.ts b/src/modules/product/DTO/updateVariant.dto.ts new file mode 100644 index 0000000..728e5ef --- /dev/null +++ b/src/modules/product/DTO/updateVariant.dto.ts @@ -0,0 +1,154 @@ +import { Expose, Type } from "class-transformer"; +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Min, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId, IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { ProductDiscountType } from "../../../common/enums/product.enum"; +import { ProductVariantModel } from "../models/productVariant.model"; +//this is for saleFormat +class WholeSaleRangeDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + from: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + to: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @Min(10000, { message: "حداقل قیمت برای کالا ۱۰۰۰۰ تومان می باشد " }) + price: number; +} +class SaleFormatDTO { + // @Expose() + // @IsNotEmpty() + // @IsBoolean() + // isWholeSale: boolean; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WholeSaleRangeDTO) + wholeSale: WholeSaleRangeDTO[]; +} + +//this is for special sale + +//======> this used for update product variant + +export class UpdateVariantDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product variant", example: "66ba2461d64d63a2494df185" }) + variantId: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the price of product in toman", example: 5432510 }) + retailPrice: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @Min(1) + @IsNumber() + @ApiProperty({ type: "number", description: "maximum number of product can purchase in one order", example: 2 }) + order_limit: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "special code of product for seller only", example: "PKF120" }) + sellerSpecialCode?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "the stock quantity of product", example: 10 }) + stock: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ValidateNested() + @Type(() => SaleFormatDTO) + @ApiProperty({ + type: "Object", + description: "object to determine how seller want to sell product", + example: { + // isWholeSale: true, + wholeSale: [ + { from: 10, to: 50, price: 50000 }, + { from: 50, to: 100, price: 45000 }, + ], + }, + }) + saleFormat?: SaleFormatDTO; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsEnum(ProductDiscountType) + @ApiProperty({ type: "string", description: "the discount type for specialSale ==> fixed | percent", example: "fixed || percent" }) + discount_type?: ProductDiscountType; + + @Expose() + @IsOptional() + @Min(10000) + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the discount value for specialSale if type is fixed", example: 50000 }) + discount_value?: number; + + @Expose() + @IsOptional() + @Min(1) + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the discount percent for specialSale if type is percent", example: 40 }) + discount_percent?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @Min(1) + @ApiProperty({ type: "number", description: "the specialSale order limit", example: 2 }) + specialSale_order_limit?: number; + + @Expose() + @IsOptional() + @Min(1) + @IsNotEmpty() + @ApiProperty({ type: "number", description: "the product quantity for specialSale", example: 20 }) + specialSale_quantity?: number; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "duration for special sale ", example: "1403/08/12" }) + specialSale_endDate?: string; +} + +export class ActivateVariantDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ProductVariantModel) + @ApiProperty({ type: "string", description: "the variant id of product variant", example: "66ba2461d64d63a2494df185" }) + variantId: string; + + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "Active status of the product variant", example: true }) + isActive: boolean; +} diff --git a/src/modules/product/Repository/comment.ts b/src/modules/product/Repository/comment.ts new file mode 100644 index 0000000..2e6e4a9 --- /dev/null +++ b/src/modules/product/Repository/comment.ts @@ -0,0 +1,109 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { ProductsCommentsQueries } from "../../../common/types/query.type"; +import { IComment } from "../models/Abstraction/IComment"; +import { CommentModel } from "../models/comment.model"; + +class CommentRepository extends BaseRepository { + constructor() { + super(CommentModel); + } + async getUserComments(userId: string) { + return this.model.aggregate([ + { + $match: { + user: new Types.ObjectId(userId), + }, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $project: { + __v: 0, + updatedAt: 0, + product: { + comments: 0, + adminComments: 0, + brand: 0, + }, + }, + }, + ]); + } + + async getProductsComments(queries: ProductsCommentsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const comments = await this.model.aggregate([ + { + $match: { + ...(queries.productId && { + product: queries.productId, + }), + ...(queries.status && { + status: queries.status, + }), + }, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $unwind: "$product", + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]); + return { + count: (comments[0]?.totalCount as number) || 0, + docs: comments[0]?.data || [], + }; + } + + async getCommentsForReport() { + return await this.model.find({ status: "pending" }).countDocuments(); + } +} + +function createCommentRepo(): CommentRepository { + return new CommentRepository(); +} + +export { createCommentRepo, CommentRepository }; diff --git a/src/modules/product/Repository/incredibleOffers.ts b/src/modules/product/Repository/incredibleOffers.ts new file mode 100644 index 0000000..87d1016 --- /dev/null +++ b/src/modules/product/Repository/incredibleOffers.ts @@ -0,0 +1,321 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { IncredibleOffers } from "../models/Abstraction/IncredibleOffers"; +import { IncredibleOffersModel } from "../models/IncredibleOffers.model"; + +export class IncredibleOffersRepo extends BaseRepository { + constructor() { + super(IncredibleOffersModel); + } + + async getIncredibleOffers(userId?: string) { + return this.model.aggregate([ + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + ...(userId + ? [ + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + ] + : []), + { + $unwind: "$product", + }, + { + $lookup: { + from: "productvariants", + localField: "variant", + foreignField: "_id", + as: "product.default_variant", + }, + }, + { + $unwind: "$product.default_variant", + }, + { + $lookup: { + from: "shops", + localField: "product.shop", + foreignField: "_id", + as: "product.shop", + }, + }, + { + $unwind: "$product.shop", + }, + + { + $lookup: { + from: "categories", + localField: "product.category", + foreignField: "_id", + as: "product.category", + }, + }, + { + $unwind: "$product.category", + }, + { + $lookup: { + from: "brands", + localField: "product.brand", + foreignField: "_id", + as: "product.brand", + }, + }, + { + $unwind: "$product.brand", + }, + + { + $lookup: { + from: "shipments", + localField: "product.shop.shipmentMethod", + foreignField: "_id", + as: "product.default_variant.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "product.default_variant.warranty", + foreignField: "_id", + as: "product.default_variant.warranty", + }, + }, + { + $unwind: { + path: "$product.default_variant.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "product.default_variant.color", + foreignField: "_id", + as: "product.default_variant.color", + }, + }, + { + $unwind: { + path: "$product.default_variant.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "product.default_variant.size", + foreignField: "_id", + as: "product.default_variant.size", + }, + }, + { + $unwind: { + path: "$product.default_variant.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "product.default_variant.meterage", + foreignField: "_id", + as: "product.default_variant.meterage", + }, + }, + { + $unwind: { + path: "$product.variant.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $limit: 10, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + } + + async getIncredibleOffersForAdmin(skip: number, limit: number) { + const docs = await this.model.aggregate([ + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "productvariants", + localField: "variant", + foreignField: "_id", + as: "variant", + }, + }, + { + $unwind: "$variant", + }, + { + $lookup: { + from: "shops", + localField: "product.shop", + foreignField: "_id", + as: "product.shop", + }, + }, + { + $unwind: "$product.shop", + }, + + { + $lookup: { + from: "categories", + localField: "product.category", + foreignField: "_id", + as: "product.category", + }, + }, + { + $unwind: "$product.category", + }, + { + $lookup: { + from: "brands", + localField: "product.brand", + foreignField: "_id", + as: "product.brand", + }, + }, + { + $unwind: "$product.brand", + }, + + { + $lookup: { + from: "shipments", + localField: "product.shop.shipmentMethod", + foreignField: "_id", + as: "variant.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variant.warranty", + foreignField: "_id", + as: "variant.warranty", + }, + }, + { + $unwind: { + path: "$variant.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variant.color", + foreignField: "_id", + as: "variant.color", + }, + }, + { + $unwind: { + path: "$variant.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variant.size", + foreignField: "_id", + as: "variant.size", + }, + }, + { + $unwind: { + path: "$variant.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variant.meterage", + foreignField: "_id", + as: "variant.meterage", + }, + }, + { + $unwind: { + path: "$variant.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + return { + count: (docs?.[0]?.totalCount as number) || 0, + incredibleOffers: docs?.[0]?.data || [], + }; + } +} + +export function createIncredibleOffersRepo(): IncredibleOffersRepo { + return new IncredibleOffersRepo(); +} diff --git a/src/modules/product/Repository/popularProduct.ts b/src/modules/product/Repository/popularProduct.ts new file mode 100644 index 0000000..b376f9b --- /dev/null +++ b/src/modules/product/Repository/popularProduct.ts @@ -0,0 +1,138 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { IPopularProduct } from "../models/Abstraction/IPopular"; +import { PopularProductModel } from "../models/popularProducts.model"; + +class PopularProductRepo extends BaseRepository { + constructor() { + super(PopularProductModel); + } + + async getPopularProducts(userId?: string) { + if (userId) { + return this.model.aggregate([ + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "shops", + localField: "product.shop", + foreignField: "_id", + as: "product.shop", + }, + }, + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$product._id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + { + $project: { + __v: 0, + updatedAt: 0, + product: { + comments: 0, + adminComments: 0, + brand: 0, + category: 0, + variants: 0, + description: 0, + metaDescription: 0, + tags: 0, + advantages: 0, + disAdvantages: 0, + specifications: 0, + __v: 0, + shop: { + telephoneNumber: 0, + shopNationalId: 0, + shopPostalCode: 0, + shipmentMethod: 0, + __v: 0, + }, + }, + }, + }, + ]); + } + return this.model.aggregate([ + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "shops", + localField: "product.shop", + foreignField: "_id", + as: "product.shop", + }, + }, + { + $project: { + __v: 0, + updatedAt: 0, + product: { + comments: 0, + adminComments: 0, + brand: 0, + category: 0, + variants: 0, + description: 0, + metaDescription: 0, + tags: 0, + advantages: 0, + disAdvantages: 0, + specifications: 0, + __v: 0, + shop: { + telephoneNumber: 0, + shopNationalId: 0, + shopPostalCode: 0, + shipmentMethod: 0, + __v: 0, + }, + }, + }, + }, + ]); + } +} +function createPopularProductRepo(): PopularProductRepo { + return new PopularProductRepo(); +} + +export { PopularProductRepo, createPopularProductRepo }; diff --git a/src/modules/product/Repository/pricehistory.ts b/src/modules/product/Repository/pricehistory.ts new file mode 100644 index 0000000..10ca9e4 --- /dev/null +++ b/src/modules/product/Repository/pricehistory.ts @@ -0,0 +1,65 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IPriceHistory } from "../models/Abstraction/IPriceHistory"; +import { PriceHistoryModel } from "../models/priceHistory.model"; + +class PriceHistoryRepo extends BaseRepository { + constructor() { + super(PriceHistoryModel); + } + + async getPriceHistory(productId: number, since?: string) { + return this.model.aggregate([ + { + $match: { + product: productId, + }, + }, + { + $lookup: { + from: "shops", + localField: "history.shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $addFields: { + "history.shop": "$shop.shopName", + // "history.date": { + // $dateFromString: { + // dateString: "$history.date", // Convert string to Date + // }, + // }, + }, + }, + ...(since + ? [ + { + $match: { + "history.date": { + $gte: since, + }, + }, + }, + ] + : []), + { + $project: { + createdAt: 0, + updatedAt: 0, + shop: 0, + __v: 0, + }, + }, + ]); + } +} + +function createPriceHistoryRepo(): PriceHistoryRepo { + return new PriceHistoryRepo(); +} + +export { createPriceHistoryRepo, PriceHistoryRepo }; diff --git a/src/modules/product/Repository/product.ts b/src/modules/product/Repository/product.ts new file mode 100644 index 0000000..4c96e96 --- /dev/null +++ b/src/modules/product/Repository/product.ts @@ -0,0 +1,2772 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { ProductMarketStatus, ProductStatus } from "../../../common/enums/product.enum"; +import { CompareSearchQueries, FilterByStatusProductsQueries } from "../../../common/types/query.type"; +import { paginationUtils } from "../../../utils/pagination.utils"; +import { TimeService } from "../../../utils/time.service"; +import { BrandSearchQueryDTO } from "../../brand/DTO/brand-search.dto"; +import { CategorySearchQueryDTO } from "../../category/DTO/category-sarch.dto"; +import { SellerProductQueryDTO } from "../../seller/DTO/sellerProductQuery.dto"; +import { IProduct } from "../models/Abstraction/IProduct"; +import { ProductModel } from "../models/product.model"; + +export class ProductRepository extends BaseRepository { + constructor() { + super(ProductModel); + } + + async getSellersProducts(shopIds: Types.ObjectId[], queryDto: SellerProductQueryDTO) { + const { limit, skip } = paginationUtils(queryDto); + + const products = await this.model.aggregate([ + { + $match: { + deleted: false, + shop: { $in: shopIds }, + ...(queryDto.categoryId && { + category: new Types.ObjectId(queryDto.categoryId), + }), + ...(queryDto.status && { + status: queryDto.status as ProductStatus, + }), + ...(queryDto.since && { + createdAt: { $gte: new Date(TimeService.convertPersianToGregorian(queryDto.since)) }, + }), + ...(queryDto.q && { + $or: [ + { _id: queryDto.q }, + { title_fa: { $regex: queryDto.q, $options: "i" } }, + { title_en: { $regex: queryDto.q, $options: "i" } }, + ], + }), + }, + }, + { + $lookup: { + from: "shops", + foreignField: "_id", + localField: "shop", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + foreignField: "_id", + localField: "category", + as: "category", + }, + }, + { + $lookup: { + from: "brands", + foreignField: "_id", + localField: "brand", + as: "brand", + }, + }, + { + $lookup: { + from: "productVariants", + foreignField: "_id", + localField: "variants", + as: "variants", + }, + }, + { + $lookup: { + from: "popularproducts", + foreignField: "product", + localField: "_id", + as: "popularData", + }, + }, + { + $lookup: { + from: "incredibleoffers", + foreignField: "product", + localField: "_id", + as: "incredibleData", + }, + }, + { + $unwind: "$category", + }, + { + $unwind: "$brand", + }, + { + $addFields: { + category_id: "$category._id", + category_title_fa: "$category.title_fa", + shopName: "$shop.shopName", + shopCode: "$shop.shopCode", + brand_title_fa: "$brand.title_fa", + variantCount: { $size: "$variants" }, + variants: "$variants", + popular: { $cond: [{ $gt: [{ $size: "$popularData" }, 0] }, true, false] }, + incredible: { $cond: [{ $gt: [{ $size: "$incredibleData" }, 0] }, true, false] }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { "price.discount_percent": -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $addFields: { + variantId: "$default_variant._id", + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + return { + count: (products[0]?.totalCount as number) || 0, + docs: products[0]?.data || [], + }; + } + + async getProductsByStatus(queries: FilterByStatusProductsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const products = await this.model.aggregate([ + { + $match: { + deleted: false, + ...(queries.status && { + status: queries.status as ProductStatus, + }), + }, + }, + { + $lookup: { + from: "shops", + foreignField: "_id", + localField: "shop", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + foreignField: "_id", + localField: "category", + as: "category", + }, + }, + { + $lookup: { + from: "brands", + foreignField: "_id", + localField: "brand", + as: "brand", + }, + }, + { + $lookup: { + from: "productvariants", + foreignField: "_id", + localField: "variants", + as: "variants", + }, + }, + { + $lookup: { + from: "popularproducts", + foreignField: "product", + localField: "_id", + as: "popularData", + }, + }, + { + $unwind: "$category", + }, + { + $unwind: "$brand", + }, + { + $addFields: { + category_title_fa: "$category.title_fa", + category_id: "$category._id", + shopName: "$shop.shopName", + brand_title_fa: "$brand.title_fa", + variantCount: { $size: "$variants" }, + popular: { $cond: [{ $gt: [{ $size: "$popularData" }, 0] }, true, false] }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + return { + count: (products[0]?.totalCount as number) || 0, + docs: products[0]?.data || [], + }; + } + + async getSellerProducts(shopId: string, queries: SellerProductQueryDTO) { + const { skip, limit } = paginationUtils(queries); + + const products = await this.model.aggregate([ + { + $match: { + deleted: false, + shop: new Types.ObjectId(shopId), + ...(queries.categoryId && { + category: new Types.ObjectId(queries.categoryId), + }), + ...(queries.status && { + status: queries.status as ProductStatus, + }), + ...(queries.since && { + createdAt: { $gte: new Date(TimeService.convertPersianToGregorian(queries.since)) }, + }), + ...(queries.q && { + $or: [ + { _id: queries.q }, + { title_fa: { $regex: queries.q, $options: "i" } }, + { title_en: { $regex: queries.q, $options: "i" } }, + ], + }), + }, + }, + { + $lookup: { + from: "shops", + foreignField: "_id", + localField: "shop", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + foreignField: "_id", + localField: "category", + as: "category", + }, + }, + { + $lookup: { + from: "brands", + foreignField: "_id", + localField: "brand", + as: "brand", + }, + }, + { + $lookup: { + from: "productvariants", + foreignField: "_id", + localField: "variants", + as: "variants", + }, + }, + { + $lookup: { + from: "popularproducts", + foreignField: "product", + localField: "_id", + as: "popularData", + }, + }, + { + $lookup: { + from: "incredibleoffers", + foreignField: "product", + localField: "_id", + as: "incredibleData", + }, + }, + { + $unwind: "$category", + }, + { + $unwind: "$brand", + }, + { + $addFields: { + category_title_fa: "$category.title_fa", + category_id: "$category._id", + shopName: "$shop.shopName", + brand_title_fa: "$brand.title_fa", + variantCount: { $size: "$variants" }, + popular: { $cond: [{ $gt: [{ $size: "$popularData" }, 0] }, true, false] }, + incredible: { $cond: [{ $gt: [{ $size: "$incredibleData" }, 0] }, true, false] }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { "price.discount_percent": -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $addFields: { + variantId: "$default_variant._id", + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + distinctCategories: [ + { + $group: { + _id: "$category_id", + title_fa: { $first: "$category_title_fa" }, + }, + }, + ], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + return { + count: (products?.[0]?.totalCount as number) || 0, + docs: products?.[0]?.data || [], + category: products?.[0]?.distinctCategories || [], + }; + } + + async getRecommendedProducts(productIds: number[], categoryIds: string[], tags: string[], limit: number = 10) { + const recommendedProducts = await this.model + .find({ + $or: [ + { category: { $in: categoryIds } }, + { tags: { $in: tags } }, // + { _id: { $in: productIds } }, + ], + _id: { $nin: productIds }, + status: ProductStatus.Approved, + deleted: false, + }) + .limit(limit); + + return recommendedProducts; + } + + async getIncredibleOffers(skip: number, limit: number) { + // if (userId) { + // return this.model.aggregate([ + // { + // $match: { + // status: ProductStatus.Approved, + // deleted: false, + // }, + // }, + // { + // $lookup: { + // from: "productvariants", + // localField: "variants", + // foreignField: "_id", + // as: "variants", + // }, + // }, + // { + // $match: { + // "variants.market_status": ProductMarketStatus.Marketable, + // variants: { $gt: [{ $size: "$variants" }, 0] }, // Ensure variants array has elements + // }, + // }, + // { + // $lookup: { + // from: "shops", + // localField: "shop", + // foreignField: "_id", + // as: "shop", + // }, + // }, + // { + // $unwind: "$shop", + // }, + // { + // $lookup: { + // from: "wishlists", + // let: { user: new Types.ObjectId(userId), productId: "$_id" }, + // pipeline: [ + // { + // $match: { + // $expr: { + // $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + // }, + // }, + // }, + // ], + // as: "isWished", + // }, + // }, + // { + // $addFields: { + // isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + // }, + // }, + // { + // $match: { + // "variants.market_status": ProductMarketStatus.Marketable, + // "variants.price.discount_percent": { $gt: 5 }, + // }, + // }, + // { + // $addFields: { + // variants: { + // $filter: { + // input: "$variants", + // as: "variant", + // cond: { $gt: ["$$variant.price.discount_percent", 5] }, + // }, + // }, + // }, + // }, + // { + // $addFields: { + // url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + // sorted_variants: { + // $sortArray: { input: "$variants", sortBy: { "price.discount_percent": -1 } }, + // }, + // }, + // }, + // { + // $addFields: { + // default_variant: { $first: "$sorted_variants" }, + // }, + // }, + // { + // $limit: 10, + // }, + // { + // $project: { + // _id: 1, + // title_fa: 1, + // title_en: 1, + // seoTitle: 1, + // seoDescription: 1, + // model: 1, + // description: 1, + // tags: 1, + // status: 1, + // shop: { + // _id: 1, + // shopName: 1, + // shopDescription: 1, + // logo: 1, + // }, + // url: 1, + // default_variant: { + // _id: 1, + // price: 1, + // stock: 1, + // market_status: 1, + // rate: 1, + // }, + // imagesUrl: 1, + // isWished: 1, + // }, + // }, + // ]); + // } + const docs = await this.model.aggregate([ + { + $match: { + status: ProductStatus.Approved, + deleted: false, + }, + }, + { + $lookup: { + from: "brands", + localField: "brand", + foreignField: "_id", + as: "brand", + }, + }, + { $unwind: "$brand" }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { $unwind: "$category" }, + + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + variants: { $gt: [{ $size: "$variants" }, 0] }, + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + "variants.price.discount_percent": { $gt: 5 }, + }, + }, + { + $addFields: { + variants: { + $filter: { + input: "$variants", + as: "variant", + cond: { $gt: ["$$variant.price.discount_percent", 5] }, + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { "price.discount_percent": -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $match: { + "default_variant.stock": { $gt: 0 }, + }, + }, + { + $addFields: { + variantId: "$default_variant._id", + }, + }, + { + $lookup: { + from: "incredibleoffers", + let: { variantId: "$variantId", productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$variant", "$$variantId"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isIncredible", + }, + }, + { + $addFields: { + isIncredible: { $gt: [{ $size: { $ifNull: ["$isIncredible", []] } }, 0] }, + }, + }, + { + $project: { + _id: 1, + title_fa: 1, + title_en: 1, + seoTitle: 1, + seoDescription: 1, + model: 1, + category: 1, + brand: 1, + description: 1, + isIncredible: 1, + variantId: 1, + tags: 1, + status: 1, + shop: { + _id: 1, + shopName: 1, + shopDescription: 1, + logo: 1, + }, + url: 1, + default_variant: { + _id: 1, + price: 1, + stock: 1, + market_status: 1, + rate: 1, + }, + imagesUrl: 1, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "totalCount" }], + }, + }, + ]); + + return { + count: (docs?.[0]?.totalCount as number) || 0, + specialSaleProducts: docs?.[0]?.data || [], + }; + } + + async getPopular(userId?: string) { + if (userId) { + // return this.model.aggregate([ + // { + // $match: { status: ProductStatus.Approved, deleted: false }, + // }, + // { + // $lookup: { + // from: "productvariants", + // localField: "variants", + // foreignField: "_id", + // as: "variants", + // }, + // }, + // { + // $match: { + // "variants.market_status": ProductMarketStatus.Marketable, + // variants: { $gt: [{ $size: "$variants" }, 0] }, // Ensure variants array has elements + // }, + // }, + // { + // $lookup: { + // from: "shops", + // localField: "shop", + // foreignField: "_id", + // as: "shop", + // }, + // }, + // { + // $unwind: "$shop", + // }, + // { + // $lookup: { + // from: "wishlists", + // let: { user: new Types.ObjectId(userId), productId: "$_id" }, + // pipeline: [ + // { + // $match: { + // $expr: { + // $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + // }, + // }, + // }, + // ], + // as: "isWished", + // }, + // }, + // { + // $addFields: { + // isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + // }, + // }, + // { + // $addFields: { + // variants: { + // $filter: { + // input: "$variants", + // as: "variant", + // cond: { $gte: ["$$variant.rate", 4] }, + // }, + // }, + // }, + // }, + // { + // $addFields: { + // url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + // sorted_variants: { + // $sortArray: { input: "$variants", sortBy: { rate: -1 } }, + // }, + // }, + // }, + // { + // $addFields: { + // default_variant: { $first: "$sorted_variants" }, + // }, + // }, + // { + // $sort: { "default_variant.rate": -1 }, + // }, + // { + // $limit: 10, + // }, + // { + // $project: { + // _id: 1, + // title_fa: 1, + // title_en: 1, + // seoTitle: 1, + // seoDescription: 1, + // model: 1, + // description: 1, + // tags: 1, + // status: 1, + // shop: { + // _id: 1, + // shopName: 1, + // shopDescription: 1, + // logo: 1, + // }, + // url: 1, + // default_variant: { + // _id: 1, + // price: 1, + // stock: 1, + // market_status: 1, + // rate: 1, + // isFreeShip: 1, + // isWholeSale: 1, + // }, + // imagesUrl: 1, + // isWished: 1, + // }, + // }, + // ]); + + return this.model.aggregate([ + { + $match: { status: ProductStatus.Approved, deleted: false }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $lookup: { + from: "popularproducts", + foreignField: "product", + localField: "_id", + as: "popularData", + }, + }, + { + $match: { + "popularData.0": { $exists: true }, + }, + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + variants: { $gt: [{ $size: "$variants" }, 0] }, // Ensure variants array has elements + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { rate: -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $match: { + "default_variant.stock": { $gt: 0 }, + }, + }, + { + $addFields: { + variants: { + $filter: { + input: "$variants", + as: "variant", + cond: { $gte: ["$$variant.rate", 4] }, + }, + }, + }, + }, + // { + // $sort: { "default_variant.rate": -1 }, + // }, + { + $limit: 10, + }, + { + $project: { + _id: 1, + title_fa: 1, + title_en: 1, + seoTitle: 1, + seoDescription: 1, + model: 1, + description: 1, + tags: 1, + status: 1, + shop: { + _id: 1, + shopName: 1, + shopDescription: 1, + logo: 1, + }, + url: 1, + default_variant: { + _id: 1, + price: 1, + stock: 1, + market_status: 1, + rate: 1, + isFreeShip: 1, + isWholeSale: 1, + }, + imagesUrl: 1, + isWished: 1, + }, + }, + ]); + } + + return this.model.aggregate([ + { + $match: { status: ProductStatus.Approved, deleted: false }, + }, + { + $lookup: { + from: "popularproducts", + foreignField: "product", + localField: "_id", + as: "popularData", + }, + }, + { + $match: { + "popularData.0": { $exists: true }, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + variants: { $gt: [{ $size: "$variants" }, 0] }, // Ensure variants array has elements + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { rate: -1 } }, + }, + popular: { $cond: [{ $gt: [{ $size: "$popularData" }, 0] }, true, false] }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $addFields: { + variants: { + $filter: { + input: "$variants", + as: "variant", + cond: { $gte: ["$$variant.rate", 4] }, + }, + }, + }, + }, + { + $limit: 10, + }, + { + $project: { + _id: 1, + title_fa: 1, + title_en: 1, + seoTitle: 1, + seoDescription: 1, + model: 1, + description: 1, + tags: 1, + status: 1, + shop: { + _id: 1, + shopName: 1, + shopDescription: 1, + logo: 1, + }, + url: 1, + default_variant: { + _id: 1, + price: 1, + stock: 1, + market_status: 1, + rate: 1, + isFreeShip: 1, + isWholeSale: 1, + }, + imagesUrl: 1, + popular: 1, + isWished: 1, + }, + }, + ]); + } + + async getLatest(userId?: string) { + if (userId) { + return this.model.aggregate([ + { + $match: { + status: ProductStatus.Approved, + deleted: false, + }, + }, + { + $sort: { createdAt: -1 }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + variants: { $gt: [{ $size: "$variants" }, 0] }, + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { createdAt: -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $match: { + "default_variant.stock": { $gt: 0 }, + }, + }, + // { + // $addFields: { + // wholeSale: { + // $cond: { + // if: { $gt: [{ $size: { $ifNull: ["$default_variant.saleFormat.wholeSale", []] } }, 0] }, + // then: true, + // else: false, + // }, + // }, + // }, + // }, + // { + // $addFields: { + // "default_variant.wholeSale": "$wholeSale", + // }, + // }, + { + $sort: { "default_variant.createdAt": -1 }, + }, + { + $limit: 10, + }, + { + $project: { + _id: 1, + title_fa: 1, + title_en: 1, + seoTitle: 1, + seoDescription: 1, + model: 1, + description: 1, + tags: 1, + status: 1, + shop: { + _id: 1, + shopName: 1, + shopDescription: 1, + logo: 1, + }, + url: 1, + default_variant: { + _id: 1, + price: 1, + stock: 1, + market_status: 1, + rate: 1, + isFreeShip: 1, + isWholeSale: 1, + }, + imagesUrl: 1, + isWished: 1, + }, + }, + ]); + } + return this.model.aggregate([ + { + $match: { + status: ProductStatus.Approved, + deleted: false, + }, + }, + { + $sort: { createdAt: -1 }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + variants: { $gt: [{ $size: "$variants" }, 0] }, + }, + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + sorted_variants: { + $sortArray: { input: "$variants", sortBy: { createdAt: -1 } }, + }, + }, + }, + { + $addFields: { + default_variant: { $first: "$sorted_variants" }, + }, + }, + { + $match: { + "default_variant.stock": { $gt: 0 }, + }, + }, + { + $sort: { "default_variant.createdAt": -1 }, + }, + { + $limit: 10, + }, + { + $project: { + _id: 1, + title_fa: 1, + title_en: 1, + seoTitle: 1, + seoDescription: 1, + model: 1, + description: 1, + tags: 1, + status: 1, + shop: { + _id: 1, + shopName: 1, + shopDescription: 1, + logo: 1, + }, + url: 1, + default_variant: { + _id: 1, + price: 1, + stock: 1, + market_status: 1, + rate: 1, + isFreeShip: 1, + isWholeSale: 1, + }, + imagesUrl: 1, + }, + }, + ]); + } + + async getProductDetails(productId: number) { + return this.model.aggregate([ + { + $match: { + _id: productId, + status: ProductStatus.Approved, + deleted: false, + }, + }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: { + path: "$category", + preserveNullAndEmptyArrays: false, + }, + }, + { + $lookup: { + from: "brands", + localField: "brand", + foreignField: "_id", + as: "brand", + }, + }, + { + $unwind: { + path: "$brand", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "_id", + foreignField: "product", + as: "allVariants", + }, + }, + { + $addFields: { + variants: { + $filter: { + input: "$allVariants", + as: "v", + cond: { + $eq: ["$$v.market_status", ProductMarketStatus.Marketable], + }, + }, + }, + }, + }, + { + $match: { + $expr: { + $or: [{ $gt: [{ $size: "$variants" }, 0] }, { $eq: ["$category.theme", "noColor_noSize"] }], + }, + }, + }, + { + $unwind: { + path: "$variants", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: { + path: "$variants.shop", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shipments", + localField: "variants.shop.shipmentMethod", + foreignField: "_id", + as: "variants.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + brand: { $first: "$brand" }, + voice: { $first: "$voice" }, + category: { $first: "$category" }, + variants: { + $push: { + $cond: [{ $ifNull: ["$variants._id", false] }, "$variants", "$$REMOVE"], + }, + }, + }, + }, + { + $addFields: { + minPriceVariants: { + $cond: { + if: { $gt: [{ $size: { $ifNull: ["$variants", []] } }, 0] }, + then: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + else: [], + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + ]); + } + + async getPriceRange(categoryIds: string[]) { + const docs = await this.model.aggregate([ + { + $match: { + // category: new Types.ObjectId(categoryId), + category: { $in: categoryIds.map((id) => new Types.ObjectId(id)) }, + deleted: false, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $group: { + _id: null, + minPrice: { $min: "$variants.price.selling_price" }, + maxPrice: { $max: "$variants.price.selling_price" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + + /********* */ + + async getPriceRangeByBrand(brandId: string) { + const docs = await this.model.aggregate([ + { + $match: { + brand: new Types.ObjectId(brandId), + deleted: false, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $group: { + _id: null, + minPrice: { $min: "$variants.price.selling_price" }, + maxPrice: { $max: "$variants.price.selling_price" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + + /********* */ + + /********* */ + + async getPriceRangeByShop(shopId: string) { + const docs = await this.model.aggregate([ + { + $match: { + shop: new Types.ObjectId(shopId), + deleted: false, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $group: { + _id: null, + minPrice: { $min: "$variants.price.selling_price" }, + maxPrice: { $max: "$variants.price.selling_price" }, + }, + }, + { + $project: { + _id: 0, + minPrice: 1, + maxPrice: 1, + }, + }, + ]); + return docs[0]; + } + + /********* */ + + async getProductsWithCategory(categoryIds: string[], queries: CategorySearchQueryDTO, userId?: string) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + // Create the base match query + let sortMatch: { [k: string]: any } = { + createdAt: -1, + }; + const variantsMatch: { [k: string]: any } = { + "variants.market_status": ProductMarketStatus.Marketable, + }; + const productMatch: { [k: string]: any } = { + status: ProductStatus.Approved, + deleted: false, + // category: new Types.ObjectId(categoryId), + category: { $in: categoryIds.map((id) => new Types.ObjectId(id)) }, + }; + + if (queries.brand) { + productMatch.brand = new Types.ObjectId(queries.brand); + } + + // Add filtering conditions dynamically + if (queries.stock) { + variantsMatch["variants.stock"] = { $gt: 0 }; + } + + if (queries.minPrice) { + variantsMatch["variants.price.selling_price"] = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + variantsMatch["variants.price.selling_price"] = { + ...variantsMatch["variants.price.selling_price"], + $lte: queries.maxPrice, + }; + } + + if (queries.wholeSale) { + variantsMatch["variants.saleFormat.wholeSale"] = { $exists: true, $not: { $size: 0 } }; + } + if (queries.sort) { + switch (queries.sort) { + case 1: // مرتبط ترین + break; + case 2: // پربازدیدترین + // sortMatch = { views: -1 }; + break; + case 3: // جدیدترین + sortMatch = { createdAt: -1 }; + break; + case 4: // پرفروش ترین + // sortMatch = { views: -1 }; + break; + case 5: // ارزانترین + sortMatch = { "default_variant.price.selling_price": 1 }; + break; + case 6: // گرانترین + sortMatch = { "default_variant.price.selling_price": -1 }; + break; + case 7: // پیشنهاد خریداران + sortMatch = { "default_variant.price.rate": -1 }; + break; + } + } + const pipeline = [ + { + $match: productMatch, + }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: { + path: "$category", + preserveNullAndEmptyArrays: true, + }, + }, + { + $match: { + $expr: { + $or: [{ $gt: [{ $size: { $ifNull: ["$variants", []] } }, 0] }, { $eq: ["$category.theme", "noColor_noSize"] }], + }, + }, + }, + ...(userId + ? [ + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { + $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0], + }, + }, + }, + ] + : []), + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: { + path: "$variants", + preserveNullAndEmptyArrays: true, + }, + }, + { + $match: { + $or: [variantsMatch, { variants: null }, { variants: { $exists: false } }], + }, + }, + { + $lookup: { + from: "brands", + localField: "brand", + foreignField: "_id", + as: "brand", + }, + }, + { + $unwind: { + path: "$brand", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: { + path: "$variants.shop", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "shipments", + localField: "variants.shop.shipmentMethod", + foreignField: "_id", + as: "variants.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + count: { $sum: 1 }, + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + brand: { $first: "$brand" }, + isWished: { $first: "$isWished" }, + category: { $first: "$category" }, + variants: { + $push: { + $cond: [{ $ifNull: ["$variants", false] }, "$variants", "$$REMOVE"], + }, + }, + }, + }, + { + $addFields: { + minPriceVariants: { + $cond: { + if: { $gt: [{ $size: { $ifNull: ["$variants", []] } }, 0] }, + then: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + else: [], + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }, { $sort: sortMatch }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $ifNull: [{ $arrayElemAt: ["$totalCount.count", 0] }, 0] }, + }, + }, + ]; + + const docs = await this.model.aggregate(pipeline); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + /********* */ + + /********* */ + + async getProductsWithBrand(brandId: string, queries: BrandSearchQueryDTO, userId?: string) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + // Create the base match query + let sortMatch: { [k: string]: any } = { + createdAt: -1, + }; + const variantsMatch: { [k: string]: any } = { + "variants.market_status": ProductMarketStatus.Marketable, + }; + const productMatch: { [k: string]: any } = { + status: ProductStatus.Approved, + deleted: false, + brand: new Types.ObjectId(brandId), + }; + + // Add filtering conditions dynamically + if (queries.stock) { + variantsMatch["variants.stock"] = { $gt: 0 }; + } + + if (queries.minPrice) { + variantsMatch["variants.price.selling_price"] = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + variantsMatch["variants.price.selling_price"] = { + ...variantsMatch["variants.price.selling_price"], + $lte: queries.maxPrice, + }; + } + + if (queries.wholeSale) { + variantsMatch["variants.saleFormat.wholeSale"] = { $exists: true, $not: { $size: 0 } }; + } + if (queries.sort) { + switch (queries.sort) { + case 1: // مرتبط ترین + break; + case 2: // پربازدیدترین + // sortMatch = { views: -1 }; + break; + case 3: // جدیدترین + sortMatch = { createdAt: -1 }; + break; + case 4: // پرفروش ترین + // sortMatch = { views: -1 }; + break; + case 5: // ارزانترین + sortMatch = { "default_variant.price.selling_price": 1 }; + break; + case 6: // گرانترین + sortMatch = { "default_variant.price.selling_price": -1 }; + break; + case 7: // پیشنهاد خریداران + sortMatch = { "default_variant.price.rate": -1 }; + break; + } + } + const pipeline = [ + { + $match: productMatch, + }, + { + $match: { + $expr: { + $and: [ + { $isArray: "$variants" }, // Ensure variants is an array + { $gt: [{ $size: "$variants" }, 0] }, // Check that its size is greater than 0 + ], + }, + }, + }, + ...(userId + ? [ + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + ] + : []), + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $match: variantsMatch, + }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "brands", + localField: "brand", + foreignField: "_id", + as: "brand", + }, + }, + { + $unwind: "$brand", + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: "$variants.shop", + }, + { + $lookup: { + from: "shipments", + localField: "variants.shop.shipmentMethod", + foreignField: "_id", + as: "variants.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + count: { $sum: 1 }, + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + brand: { $first: "$brand" }, + isWished: { $first: "$isWished" }, + category: { $first: "$category" }, + variants: { $push: "$variants" }, + }, + }, + { + $addFields: { + minPriceVariants: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }, { $sort: sortMatch }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]; + const docs = await this.model.aggregate(pipeline); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + /********* */ + + /********* */ + + async getProductsWithShop(shopId: string, queries: BrandSearchQueryDTO, userId?: string) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + // Create the base match query + let sortMatch: { [k: string]: any } = { + createdAt: -1, + }; + const variantsMatch: { [k: string]: any } = { + "variants.market_status": ProductMarketStatus.Marketable, + }; + const productMatch: { [k: string]: any } = { + status: ProductStatus.Approved, + deleted: false, + shop: new Types.ObjectId(shopId), + }; + + // Add filtering conditions dynamically + if (queries.stock) { + variantsMatch["variants.stock"] = { $gt: 0 }; + } + + if (queries.minPrice) { + variantsMatch["variants.price.selling_price"] = { $gte: queries.minPrice }; + } + + if (queries.maxPrice) { + variantsMatch["variants.price.selling_price"] = { + ...variantsMatch["variants.price.selling_price"], + $lte: queries.maxPrice, + }; + } + + if (queries.wholeSale) { + variantsMatch["variants.saleFormat.wholeSale"] = { $exists: true, $not: { $size: 0 } }; + } + if (queries.sort) { + switch (queries.sort) { + case 1: // مرتبط ترین + break; + case 2: // پربازدیدترین + // sortMatch = { views: -1 }; + break; + case 3: // جدیدترین + sortMatch = { createdAt: -1 }; + break; + case 4: // پرفروش ترین + // sortMatch = { views: -1 }; + break; + case 5: // ارزانترین + sortMatch = { "default_variant.price.selling_price": 1 }; + break; + case 6: // گرانترین + sortMatch = { "default_variant.price.selling_price": -1 }; + break; + case 7: // پیشنهاد خریداران + sortMatch = { "default_variant.price.rate": -1 }; + break; + } + } + const pipeline = [ + { + $match: productMatch, + }, + { + $match: { + $expr: { + $and: [ + { $isArray: "$variants" }, // Ensure variants is an array + { $gt: [{ $size: "$variants" }, 0] }, // Check that its size is greater than 0 + ], + }, + }, + }, + ...(userId + ? [ + { + $lookup: { + from: "wishlists", + let: { user: new Types.ObjectId(userId), productId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [{ $eq: ["$user", "$$user"] }, { $eq: ["$product", "$$productId"] }], + }, + }, + }, + ], + as: "isWished", + }, + }, + { + $addFields: { + isWished: { $gt: [{ $size: { $ifNull: ["$isWished", []] } }, 0] }, + }, + }, + ] + : []), + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $match: variantsMatch, + }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "brands", + localField: "brand", + foreignField: "_id", + as: "brand", + }, + }, + { + $unwind: "$brand", + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: "$variants.shop", + }, + { + $lookup: { + from: "shipments", + localField: "variants.shop.shipmentMethod", + foreignField: "_id", + as: "variants.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + count: { $sum: 1 }, + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + brand: { $first: "$brand" }, + isWished: { $first: "$isWished" }, + category: { $first: "$category" }, + variants: { $push: "$variants" }, + }, + }, + { + $addFields: { + minPriceVariants: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }, { $sort: sortMatch }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]; + const docs = await this.model.aggregate(pipeline); + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + /********* */ + + async getProductsForCompareSearch(categoryId: string, queries: CompareSearchQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const docs = await this.model.aggregate([ + { + $match: { + status: ProductStatus.Approved, + category: new Types.ObjectId(categoryId), + deleted: false, + }, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $match: { + "variants.market_status": ProductMarketStatus.Marketable, + }, + }, + { + $match: { + variants: { $gt: [{ $size: "$variants" }, 0] }, // Ensure variants array has elements + }, + }, + { + $lookup: { + from: "categories", + localField: "category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: "$variants.shop", + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + // count: { $sum: 1 }, + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + category: { $first: "$category" }, + variants: { $push: "$variants" }, + }, + }, + { + $addFields: { + minPriceVariants: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getProductSpecification(productIds: string[] | string) { + const matchQuery: { [k: string]: unknown } = { + deleted: false, + }; + + if (typeof productIds === "string") { + matchQuery._id = +productIds; + } else { + matchQuery._id = { $in: productIds.map((id) => +id) }; + } + + return this.model.aggregate([ + { + $match: matchQuery, + }, + { + $lookup: { + from: "productvariants", + localField: "variants", + foreignField: "_id", + as: "variants", + }, + }, + { + $unwind: "$variants", + }, + { + $lookup: { + from: "shops", + localField: "variants.shop", + foreignField: "_id", + as: "variants.shop", + }, + }, + { + $unwind: "$variants.shop", + }, + { + $lookup: { + from: "shipments", + localField: "variants.shop.shipmentMethod", + foreignField: "_id", + as: "variants.shipmentMethod", + }, + }, + { + $lookup: { + from: "warranties", + localField: "variants.warranty", + foreignField: "_id", + as: "variants.warranty", + }, + }, + { + $unwind: { + path: "$variants.warranty", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "colors", + localField: "variants.color", + foreignField: "_id", + as: "variants.color", + }, + }, + { + $unwind: { + path: "$variants.color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "sizes", + localField: "variants.size", + foreignField: "_id", + as: "variants.size", + }, + }, + { + $unwind: { + path: "$variants.size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "meterages", + localField: "variants.meterage", + foreignField: "_id", + as: "variants.meterage", + }, + }, + { + $unwind: { + path: "$variants.meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $group: { + _id: "$_id", + title_fa: { $first: "$title_fa" }, + title_en: { $first: "$title_en" }, + seoTitle: { $first: "$seoTitle" }, + seoDescription: { $first: "$seoDescription" }, + model: { $first: "$model" }, + source: { $first: "$source" }, + description: { $first: "$description" }, + metaDescription: { $first: "$metaDescription" }, + tags: { $first: "$tags" }, + advantages: { $first: "$advantages" }, + disAdvantages: { $first: "$disAdvantages" }, + imagesUrl: { $first: "$imagesUrl" }, + isFake: { $first: "$isFake" }, + specifications: { $first: "$specifications" }, + variants: { $push: "$variants" }, + }, + }, + + { + $addFields: { + minPriceVariants: { + $filter: { + input: "$variants", + as: "variant", + cond: { + $eq: ["$$variant.price.selling_price", { $min: "$variants.price.selling_price" }], + }, + }, + }, + }, + }, + { + $addFields: { + url: { $concat: ["/product/SHP-", { $toString: "$_id" }] }, + default_variant: { $first: "$minPriceVariants" }, + }, + }, + ]); + } + + async getProductCount() { + return this.model.countDocuments({ deleted: false }); + } + + async getPendingProductCount() { + return this.model.countDocuments({ status: ProductStatus.Pending, deleted: false }); + } +} + +export function createProductRepo(): ProductRepository { + return new ProductRepository(); +} diff --git a/src/modules/product/Repository/productAd.ts b/src/modules/product/Repository/productAd.ts new file mode 100644 index 0000000..a5328c0 --- /dev/null +++ b/src/modules/product/Repository/productAd.ts @@ -0,0 +1,14 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IProductAd } from "../models/Abstraction/IProductAd"; +import { ProductAdModel } from "../models/productAd.model"; + +class ProductAdRepo extends BaseRepository { + constructor() { + super(ProductAdModel); + } +} +function createProductAdRepo(): ProductAdRepo { + return new ProductAdRepo(); +} + +export { ProductAdRepo, createProductAdRepo }; diff --git a/src/modules/product/Repository/productObserve.ts b/src/modules/product/Repository/productObserve.ts new file mode 100644 index 0000000..d7a7175 --- /dev/null +++ b/src/modules/product/Repository/productObserve.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IProductObserve } from "../models/Abstraction/IProductObserve"; +import { ProductObserveModel } from "../models/productObserve.model"; + +class ProductObserveRepo extends BaseRepository { + constructor() { + super(ProductObserveModel); + } +} + +function createProductObserveRepo(): ProductObserveRepo { + return new ProductObserveRepo(); +} + +export { ProductObserveRepo, createProductObserveRepo }; diff --git a/src/modules/product/Repository/productReport.ts b/src/modules/product/Repository/productReport.ts new file mode 100644 index 0000000..cdb4039 --- /dev/null +++ b/src/modules/product/Repository/productReport.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IProductReport } from "../models/Abstraction/IProductReport"; +import { ProductReportModel } from "../models/productReport.model"; + +class ProductReportRepo extends BaseRepository { + constructor() { + super(ProductReportModel); + } +} + +function createProductReportRepo(): ProductReportRepo { + return new ProductReportRepo(); +} + +export { ProductReportRepo, createProductReportRepo }; diff --git a/src/modules/product/Repository/productRequest.ts b/src/modules/product/Repository/productRequest.ts new file mode 100644 index 0000000..dc83a13 --- /dev/null +++ b/src/modules/product/Repository/productRequest.ts @@ -0,0 +1,17 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IProductRequest } from "../models/Abstraction/IProductRequest"; +import { ProductRequestModel } from "../models/productRequest.model"; + +class ProductRequestRepo extends BaseRepository { + constructor() { + super(ProductRequestModel); + } +} + +function createProductRequestRepo(): ProductRequestRepo { + return new ProductRequestRepo(); +} + +export { createProductRequestRepo, ProductRequestRepo }; + +// { $group: { _id: null, count: { $sum: 1 }, items: { $push: "$$ROOT" } } }, diff --git a/src/modules/product/Repository/productVarinat.ts b/src/modules/product/Repository/productVarinat.ts new file mode 100644 index 0000000..2dc24b7 --- /dev/null +++ b/src/modules/product/Repository/productVarinat.ts @@ -0,0 +1,486 @@ +import { Types, isValidObjectId } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { ProductMarketStatus } from "../../../common/enums/product.enum"; +import { VariantQueries } from "../../../common/types/query.type"; +import { IProductVariant } from "../models/Abstraction/IProductVariant"; +import { ProductVariantModel } from "../models/productVariant.model"; + +export class ProductVariantRepository extends BaseRepository { + constructor() { + super(ProductVariantModel); + } + + async getProductVariants(shopId: string, productId: number, queries: VariantQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const matchQuery: any = { + shop: new Types.ObjectId(shopId), + product: productId, + }; + + const sortBy: any = { + createdAt: 1, + }; + if (queries.q) { + matchQuery.$or = [{ _id: isValidObjectId(queries.q) ? new Types.ObjectId(queries.q) : undefined }, { sellerSpecialCode: queries.q }]; + } + if (queries.includeAd) { + matchQuery.searchAd = { $exists: true }; + } + // if (queries.shipmentMethod) { + // matchQuery.shipmentMethod = { $eq: queries.shipmentMethod }; + // } + if (queries.specialSale) { + matchQuery["price.is_specialSale"] = true; + } + if (queries.stock) { + matchQuery.stock = { $gt: 0 }; + } + if (queries.variantStatus) { + matchQuery.market_status = ProductMarketStatus.Marketable; + } + + if (Array.isArray(queries.sort)) { + queries.sort.forEach((value) => (sortBy[value] = -1)); + } else if (queries.sort) { + sortBy[queries.sort] = -1; + } + + const docs = await this.model.aggregate([ + { + $match: matchQuery, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + localField: "product.category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "colors", + localField: "color", + foreignField: "_id", + as: "color", + }, + }, + { + $lookup: { + from: "sizes", + localField: "size", + foreignField: "_id", + as: "size", + }, + }, + { + $lookup: { + from: "meterages", + localField: "meterage", + foreignField: "_id", + as: "meterage", + }, + }, + { + $lookup: { + from: "shipments", + localField: "shop.shipmentMethod", + foreignField: "_id", + as: "shipmentMethod", + }, + }, + { + $unwind: { + path: "$color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $addFields: { + product_id: "$product._id", + productCode: { $concat: ["SHP-", { $toString: "$product._id" }] }, + product_status: "$product.status", + title_fa: "$product.title_fa", + title_en: "$product.title_en", + model: "$product.model", + category_theme: "$category.theme", + category_title_fa: "$category.title_fa", + coverImage: "$product.imagesUrl.cover", + is_variant_active: { + $cond: { + if: { $eq: ["$market_status", ProductMarketStatus.Marketable] }, + then: true, + else: false, + }, + }, + }, + }, + { + $sort: sortBy, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $project: { + category: 0, + product: 0, + shop: 0, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getAllProductsVariants(shopId: string, queries: VariantQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const matchQuery: any = { + shop: new Types.ObjectId(shopId), + }; + + const sortBy: any = { + createdAt: 1, + }; + if (queries.q) { + matchQuery.$or = [{ _id: isValidObjectId(queries.q) ? new Types.ObjectId(queries.q) : undefined }, { sellerSpecialCode: queries.q }]; + } + if (queries.includeAd) { + matchQuery.searchAd = { $exists: true }; + } + // if (queries.shipmentMethod) { + // matchQuery.shipmentMethod = { $eq: queries.shipmentMethod }; + // } + if (queries.specialSale) { + matchQuery["price.is_specialSale"] = true; + } + if (queries.stock) { + matchQuery.stock = { $gt: 0 }; + } + if (queries.variantStatus) { + matchQuery.market_status = ProductMarketStatus.Marketable; + } + + if (Array.isArray(queries.sort)) { + queries.sort.forEach((value) => (sortBy[value] = -1)); + } else if (queries.sort) { + sortBy[queries.sort] = -1; + } + + const docs = await this.model.aggregate([ + { + $match: matchQuery, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + localField: "product.category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "colors", + localField: "color", + foreignField: "_id", + as: "color", + }, + }, + { + $lookup: { + from: "sizes", + localField: "size", + foreignField: "_id", + as: "size", + }, + }, + { + $lookup: { + from: "meterages", + localField: "meterage", + foreignField: "_id", + as: "meterage", + }, + }, + { + $lookup: { + from: "shipments", + localField: "shop.shipmentMethod", + foreignField: "_id", + as: "shipmentMethod", + }, + }, + { + $unwind: { + path: "$color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $addFields: { + product_id: "$product._id", + productCode: { $concat: ["SHP-", { $toString: "$product._id" }] }, + product_status: "$product.status", + title_fa: "$product.title_fa", + title_en: "$product.title_en", + model: "$product.model", + category_theme: "$category.theme", + category_title_fa: "$category.title_fa", + coverImage: "$product.imagesUrl.cover", + is_variant_active: { + $cond: { + if: { $eq: ["$market_status", ProductMarketStatus.Marketable] }, + then: true, + else: false, + }, + }, + }, + }, + { + $sort: sortBy, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $project: { + category: 0, + product: 0, + shop: 0, + }, + }, + ]); + + return { + count: (docs[0]?.totalCount as number) || 0, + docs: docs[0]?.data || [], + }; + } + + async getSingleVariant(shopId: string, productId: number, variantId: string) { + return this.model.aggregate([ + { + $match: { + _id: new Types.ObjectId(variantId), + shop: new Types.ObjectId(shopId), + product: productId, + }, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "shops", + localField: "shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "categories", + localField: "product.category", + foreignField: "_id", + as: "category", + }, + }, + { + $unwind: "$category", + }, + { + $lookup: { + from: "colors", + localField: "color", + foreignField: "_id", + as: "color", + }, + }, + { + $lookup: { + from: "sizes", + localField: "size", + foreignField: "_id", + as: "size", + }, + }, + { + $lookup: { + from: "meterages", + localField: "meterage", + foreignField: "_id", + as: "meterage", + }, + }, + { + $lookup: { + from: "shipments", + localField: "shop.shipmentMethod", + foreignField: "_id", + as: "shipmentMethod", + }, + }, + { + $unwind: { + path: "$color", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$size", + preserveNullAndEmptyArrays: true, + }, + }, + { + $unwind: { + path: "$meterage", + preserveNullAndEmptyArrays: true, + }, + }, + { + $addFields: { + product_id: "$product._id", + productCode: { $concat: ["SHP-", { $toString: "$product._id" }] }, + product_status: "$product.status", + category_theme: "$category.theme", + title_fa: "$product.title_fa", + title_en: "$product.title_en", + model: "$product.model", + category_title_fa: "$category.title_fa", + coverImage: "$product.imagesUrl.cover", + is_variant_active: { + $cond: { + if: { $eq: ["$market_status", ProductMarketStatus.Marketable] }, + then: true, + else: false, + }, + }, + }, + }, + { + $project: { + category: 0, + product: 0, + shop: 0, + }, + }, + ]); + } +} + +export function createProductVariantRepo(): ProductVariantRepository { + return new ProductVariantRepository(); +} diff --git a/src/modules/product/Repository/question.ts b/src/modules/product/Repository/question.ts new file mode 100644 index 0000000..f5e2b4f --- /dev/null +++ b/src/modules/product/Repository/question.ts @@ -0,0 +1,90 @@ +import { Types } from "mongoose"; + +import { BaseRepository } from "../../../common/base/repository"; +import { Question_CommentStatus } from "../../../common/enums/question_comment.enum"; +import { ProductsQuestionsQueries } from "../../../common/types/query.type"; +import { IQuestion } from "../models/Abstraction/IQuestion"; +import { QuestionModel } from "../models/question.model"; + +class QuestionRepository extends BaseRepository { + constructor() { + super(QuestionModel); + } + + async getUserQuestions(userId: string) { + return this.model.aggregate([ + { + $match: { + user: new Types.ObjectId(userId), + }, + }, + ]); + } + + async getProductsQuestions(queries: ProductsQuestionsQueries) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const questions = await this.model.aggregate([ + { + $match: { + ...(queries.productId && { + product: queries.productId, + }), + ...(queries.status && { + status: queries.status, + }), + }, + }, + { + $lookup: { + from: "products", + localField: "product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $lookup: { + from: "users", + localField: "user", + foreignField: "_id", + as: "user", + }, + }, + { + $unwind: "$product", + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]); + return { + count: (questions[0]?.totalCount as number) || 0, + docs: questions[0]?.data || [], + }; + } + + async getQuestionsForReport() { + const questions = await this.model.find({ status: Question_CommentStatus.Pending }).countDocuments(); + return questions; + } +} + +function createQuestionRepo(): QuestionRepository { + return new QuestionRepository(); +} + +export { QuestionRepository, createQuestionRepo }; diff --git a/src/modules/product/Repository/reportQuestion.ts b/src/modules/product/Repository/reportQuestion.ts new file mode 100644 index 0000000..3a5eea7 --- /dev/null +++ b/src/modules/product/Repository/reportQuestion.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IReportQuestion } from "../models/Abstraction/IReportQuestion"; +import { ReportQuestionModel } from "../models/reportQuestion.model"; + +class ReportQuestionRepo extends BaseRepository { + constructor() { + super(ReportQuestionModel); + } +} + +function createReportQuestionRepo(): ReportQuestionRepo { + return new ReportQuestionRepo(); +} + +export { ReportQuestionRepo, createReportQuestionRepo }; diff --git a/src/modules/product/models/Abstraction/IComment.ts b/src/modules/product/models/Abstraction/IComment.ts new file mode 100644 index 0000000..baa1409 --- /dev/null +++ b/src/modules/product/models/Abstraction/IComment.ts @@ -0,0 +1,14 @@ +import { Types } from "mongoose"; + +import { Question_CommentStatus } from "../../../../common/enums/question_comment.enum"; + +export interface IComment { + title: string; + advantage: string[]; + disAdvantage: string[]; + content: string; + status: Question_CommentStatus; + user: Types.ObjectId; + product: number; + rate: number; +} diff --git a/src/modules/product/models/Abstraction/IPopular.ts b/src/modules/product/models/Abstraction/IPopular.ts new file mode 100644 index 0000000..3d29e66 --- /dev/null +++ b/src/modules/product/models/Abstraction/IPopular.ts @@ -0,0 +1,4 @@ +export interface IPopularProduct { + product: number; + isActive: boolean; // Whether the popular is currently active +} diff --git a/src/modules/product/models/Abstraction/IPriceHistory.ts b/src/modules/product/models/Abstraction/IPriceHistory.ts new file mode 100644 index 0000000..da58e67 --- /dev/null +++ b/src/modules/product/models/Abstraction/IPriceHistory.ts @@ -0,0 +1,15 @@ +import { Types } from "mongoose"; + +export interface IHistory { + shop: Types.ObjectId; + selling_price: number; + retail_price: number; + date: string; +} + +export interface IPriceHistory { + _id: number; + product: number; + title: string; //variant title + history: IHistory[]; +} diff --git a/src/modules/product/models/Abstraction/IProduct.ts b/src/modules/product/models/Abstraction/IProduct.ts new file mode 100644 index 0000000..f04b201 --- /dev/null +++ b/src/modules/product/models/Abstraction/IProduct.ts @@ -0,0 +1,42 @@ +import { Types } from "mongoose"; + +import { CreateProductStep, ProductSource, ProductStatus } from "../../../../common/enums/product.enum"; + +export interface IProduct { + _id: number; + title_fa: string; + title_en: string; + seoTitle: string; + seoDescription: string; + model: string; + source: ProductSource; + description: string; + metaDescription: string; + advantages: Array; + disAdvantages: Array; + rate: number; + totalRate: number; + tags: string[]; + specifications: ISpecifications[]; + category: Types.ObjectId; + brand: Types.ObjectId; + shop: Types.ObjectId; + imagesUrl: IProductImage; + status: ProductStatus; + adminComments: string; + step: CreateProductStep; + isFake: boolean; + //TODO:delete this field later + variants: Types.ObjectId[]; + voice?: string; + deleted: boolean; +} + +export interface IProductImage { + cover: string; + list: string[]; +} +export interface ISpecifications { + title: string; + values: string[]; +} diff --git a/src/modules/product/models/Abstraction/IProductAd.ts b/src/modules/product/models/Abstraction/IProductAd.ts new file mode 100644 index 0000000..67c33a7 --- /dev/null +++ b/src/modules/product/models/Abstraction/IProductAd.ts @@ -0,0 +1,19 @@ +import { Types } from "mongoose"; + +export enum AdType { + Click = "click", + Daily = "daily", +} + +export interface IProductAd { + product: number; + variant: Types.ObjectId; + seller: Types.ObjectId; + type: AdType; // type of ad: click-based or duration-based + cost: number; // Ad cost per click or for the entire duration + clickCount: number; // Number of clicks (only for click-based ads) + maxClicks: number; // Max allowed clicks (for click-based ads) + startDate: string; // Start date (for duration-based ads) + endDate: string; // End date (for duration-based ads) + isActive: boolean; // Whether the ad is currently active +} diff --git a/src/modules/product/models/Abstraction/IProductObserve.ts b/src/modules/product/models/Abstraction/IProductObserve.ts new file mode 100644 index 0000000..5dd35aa --- /dev/null +++ b/src/modules/product/models/Abstraction/IProductObserve.ts @@ -0,0 +1,8 @@ +import { Types } from "mongoose"; + +export interface IProductObserve { + user: Types.ObjectId; + product: number; + variant: Types.ObjectId; + registeredPrice: number; +} diff --git a/src/modules/product/models/Abstraction/IProductReport.ts b/src/modules/product/models/Abstraction/IProductReport.ts new file mode 100644 index 0000000..ac9eddf --- /dev/null +++ b/src/modules/product/models/Abstraction/IProductReport.ts @@ -0,0 +1,11 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IProductReport { + user: Types.ObjectId; + product: number; + reason: number[]; + description: string; + status: StatusEnum; +} diff --git a/src/modules/product/models/Abstraction/IProductRequest.ts b/src/modules/product/models/Abstraction/IProductRequest.ts new file mode 100644 index 0000000..af7be42 --- /dev/null +++ b/src/modules/product/models/Abstraction/IProductRequest.ts @@ -0,0 +1,28 @@ +import { Types } from "mongoose"; + +import { IProductImage } from "./IProduct"; +import { IDimensions } from "./IProductVariant"; +import { ProductSource, ProductStatus } from "../../../../common/enums/product.enum"; + +export interface IProductRequest { + seller: Types.ObjectId; + shop: Types.ObjectId; + brand: Types.ObjectId; + category: Types.ObjectId; + source: ProductSource; + productName: string; + productType: string; + description: string; + dimensions: IDimensions; + advantages: Array; + disAdvantages: Array; + // + requestPhotography: boolean; + requestPhotosCount: number; + expertReview: boolean; + unboxingVideo: boolean; + imagesUrl: IProductImage; + payment: Types.ObjectId; + requestStatus: ProductStatus; + adminComments: string; +} diff --git a/src/modules/product/models/Abstraction/IProductVariant.ts b/src/modules/product/models/Abstraction/IProductVariant.ts new file mode 100644 index 0000000..c607248 --- /dev/null +++ b/src/modules/product/models/Abstraction/IProductVariant.ts @@ -0,0 +1,64 @@ +import { Types } from "mongoose"; + +import { ProductDiscountType, ProductMarketStatus } from "../../../../common/enums/product.enum"; + +export interface IProductVariant { + _id: Types.ObjectId; + product: number; + statistics: IStatistics; + warranty: number; + shop: Types.ObjectId; + sellerCode: string; + market_status: ProductMarketStatus; + postingTime: number; + price: IPrice; + stock: number; + order_limit: number; + isWholeSale: boolean; + isFreeShip: boolean; + saleFormat: ISaleFormat; + dimensions: IDimensions; + sellerSpecialCode: string; + color?: number; + size?: number; + meterage?: number; +} + +export interface IPrice { + order_limit: number; + retailPrice: number; + selling_price: number; + //specialSale + is_specialSale: boolean; + discount_percent: number; + specialSale_order_limit: number; + specialSale_quantity: number; + specialSale_endDate: string; +} + +//TODO:check for type +export interface ISpecialSale { + endDate: string; + type: ProductDiscountType; + discountValue: number; + order_limit: number; + quantity: number; + priceAfterDiscount: number; +} + +export interface IStatistics { + satisfied: { rate: number; count: number }; + dissatisfied: { rate: number; count: number }; +} + +export interface ISaleFormat { + // isWholeSale: boolean; + wholeSale: Array<{ from: number; to: number; price: number }>; +} + +export interface IDimensions { + package_weight: number; + package_height: number; + package_length: number; + package_width: number; +} diff --git a/src/modules/product/models/Abstraction/IQuestion.ts b/src/modules/product/models/Abstraction/IQuestion.ts new file mode 100644 index 0000000..4236d08 --- /dev/null +++ b/src/modules/product/models/Abstraction/IQuestion.ts @@ -0,0 +1,18 @@ +import { Types } from "mongoose"; + +import { Question_CommentStatus } from "../../../../common/enums/question_comment.enum"; +import { OwnerRef } from "../../../shop/models/Abstraction/IShop"; + +export interface IQuestion { + content: string; + status: Question_CommentStatus; + user: Types.ObjectId; + product: number; + answers?: IAnswer[]; +} + +export interface IAnswer { + content: string; + owner: Types.ObjectId; + ownerRef: OwnerRef; +} diff --git a/src/modules/product/models/Abstraction/IReportQuestion.ts b/src/modules/product/models/Abstraction/IReportQuestion.ts new file mode 100644 index 0000000..2b5346d --- /dev/null +++ b/src/modules/product/models/Abstraction/IReportQuestion.ts @@ -0,0 +1,14 @@ +export enum ReportQuestionType { + TEXT = "text", + SELECT = "select", + RADIO = "radio", + CHECKBOX = "checkbox", +} + +export interface IReportQuestion { + _id: number; + title: string; + type: ReportQuestionType; + placeholder: string; + // related_questions: number[]; +} diff --git a/src/modules/product/models/Abstraction/IncredibleOffers.ts b/src/modules/product/models/Abstraction/IncredibleOffers.ts new file mode 100644 index 0000000..d921492 --- /dev/null +++ b/src/modules/product/models/Abstraction/IncredibleOffers.ts @@ -0,0 +1,6 @@ +import { Types } from "mongoose"; + +export interface IncredibleOffers { + product: number; + variant: Types.ObjectId; +} diff --git a/src/modules/product/models/IncredibleOffers.model.ts b/src/modules/product/models/IncredibleOffers.model.ts new file mode 100644 index 0000000..5fb731c --- /dev/null +++ b/src/modules/product/models/IncredibleOffers.model.ts @@ -0,0 +1,14 @@ +import { Schema, model } from "mongoose"; + +import { IncredibleOffers } from "./Abstraction/IncredibleOffers"; + +const IncredibleOffersSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true }, + variant: { type: Schema.Types.ObjectId, ref: "ProductVariant", required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const IncredibleOffersModel = model("IncredibleOffers", IncredibleOffersSchema); +export { IncredibleOffersModel }; diff --git a/src/modules/product/models/comment.model.ts b/src/modules/product/models/comment.model.ts new file mode 100644 index 0000000..9b4009e --- /dev/null +++ b/src/modules/product/models/comment.model.ts @@ -0,0 +1,39 @@ +import { Schema, model } from "mongoose"; + +import { IComment } from "./Abstraction/IComment"; +import { Question_CommentStatus } from "../../../common/enums/question_comment.enum"; + +const CommentSchema = new Schema( + { + title: { type: String, required: true }, + advantage: { type: [String] }, + disAdvantage: { type: [String] }, + content: { type: String, required: true }, + status: { type: String, enum: Question_CommentStatus, default: Question_CommentStatus.Pending }, + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + product: { type: Number, ref: "Product", required: true }, + rate: { type: Number, required: true, min: 1, max: 5 }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); +CommentSchema.pre(["find", "findOne"], function (next) { + this.populate({ path: "user" }); + next(); +}); + +CommentSchema.pre(["find", "findOne"], function (next) { + this.populate({ + path: "product", + populate: ["brand", "category", "shop"], + select: { adminComments: 0, variants: 0 }, + }); + next(); +}); + +const CommentModel = model("Comment", CommentSchema); + +export { CommentModel }; diff --git a/src/modules/product/models/popularProducts.model.ts b/src/modules/product/models/popularProducts.model.ts new file mode 100644 index 0000000..07828e7 --- /dev/null +++ b/src/modules/product/models/popularProducts.model.ts @@ -0,0 +1,14 @@ +import { Schema, model } from "mongoose"; + +import { IPopularProduct } from "./Abstraction/IPopular"; + +const PopularProductSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true }, + isActive: { type: Boolean, default: true }, // Whether popular is currently active + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const PopularProductModel = model("PopularProduct", PopularProductSchema); +export { PopularProductModel }; diff --git a/src/modules/product/models/priceHistory.model.ts b/src/modules/product/models/priceHistory.model.ts new file mode 100644 index 0000000..c4be15a --- /dev/null +++ b/src/modules/product/models/priceHistory.model.ts @@ -0,0 +1,31 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IHistory, IPriceHistory } from "./Abstraction/IPriceHistory"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const HistorySchema = new Schema( + { + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + selling_price: { type: Number, required: true }, + retail_price: { type: Number, required: true }, + date: { type: String }, + }, + { _id: false }, +); + +const PriceHistorySchema = new Schema( + { + _id: Number, + product: { type: Number, ref: "Product", required: true }, + title: { type: String, default: "" }, + history: { type: [HistorySchema], default: [] }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, _id: false, id: false }, +); + +PriceHistorySchema.plugin(AutoIncrement, { id: "priceHistory_id", inc_field: "_id" }); +const PriceHistoryModel = model("PriceHistory", PriceHistorySchema); + +export { PriceHistoryModel }; diff --git a/src/modules/product/models/product.model.ts b/src/modules/product/models/product.model.ts new file mode 100644 index 0000000..351499e --- /dev/null +++ b/src/modules/product/models/product.model.ts @@ -0,0 +1,70 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IProduct, IProductImage, ISpecifications } from "./Abstraction/IProduct"; +import { CreateProductStep, ProductSource, ProductStatus } from "../../../common/enums/product.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const productSpecifications = new Schema( + { + title: String, + values: { type: [String] }, + }, + { id: false, _id: false }, +); +export const productImageSchema = new Schema( + { + cover: String, + list: { type: [String] }, + }, + { id: false, _id: false }, +); + +const productSchema = new Schema( + { + _id: Number, + title_fa: { type: String, required: true }, + title_en: { type: String, required: true }, + seoTitle: { type: String, default: null }, + seoDescription: { type: String, default: null }, + model: { type: String, required: true }, + source: { type: String, enum: ProductSource, default: ProductSource.LOCAL, required: true }, + description: { type: String, default: null }, + metaDescription: { type: String, default: null }, + tags: { type: [String], default: [] }, + advantages: { type: [String], default: [] }, + disAdvantages: { type: [String], default: [] }, + rate: { type: Number }, + totalRate: { type: Number, default: 0 }, + specifications: { type: [productSpecifications], default: [] }, + category: { type: Schema.Types.ObjectId, ref: "Category", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + brand: { type: Schema.Types.ObjectId, ref: "Brand", required: true }, + imagesUrl: { type: productImageSchema }, + status: { type: String, enum: ProductStatus, default: ProductStatus.Draft }, + adminComments: { type: String, default: null }, + step: { type: Number, enum: CreateProductStep, default: CreateProductStep.Detail }, + isFake: { type: Boolean, required: true }, + variants: { type: [Schema.Types.ObjectId], ref: "ProductVariant" }, + voice: { type: String, default: null }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, _id: false, id: false }, +); + +productSchema.virtual("url").get(function () { + return `/product/SHP-${this._id}/${this.title_fa}`; +}); + +// productSchema.pre("findOneAndDelete", async function (next) { +// const productId = this.getQuery()["_id"]; + +// await mongoose.model("ProductVariant").deleteMany({ product: productId }); + +// next(); +// }); +productSchema.plugin(AutoIncrement, { id: "product_id", start_seq: 100000 }); +const ProductModel = model("Product", productSchema); + +export { ProductModel }; diff --git a/src/modules/product/models/productAd.model.ts b/src/modules/product/models/productAd.model.ts new file mode 100644 index 0000000..c5900db --- /dev/null +++ b/src/modules/product/models/productAd.model.ts @@ -0,0 +1,22 @@ +import { Schema, model } from "mongoose"; + +import { AdType, IProductAd } from "./Abstraction/IProductAd"; + +const ProductAdSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true }, + variant: { type: Schema.Types.ObjectId, ref: "ProductVariant", required: true }, + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + type: { type: String, enum: AdType, required: true }, + cost: { type: Number, required: true }, + clickCount: { type: Number, default: 0 }, // for click-based ads + maxClicks: { type: Number, default: null }, // max clicks (for click-based) + startDate: { type: String }, // for duration-based ads + endDate: { type: String }, // for duration-based ads + isActive: { type: Boolean, default: true }, // Whether ad is currently active + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ProductAdModel = model("ProductAd", ProductAdSchema); +export { ProductAdModel }; diff --git a/src/modules/product/models/productObserve.model.ts b/src/modules/product/models/productObserve.model.ts new file mode 100644 index 0000000..ae93bbf --- /dev/null +++ b/src/modules/product/models/productObserve.model.ts @@ -0,0 +1,21 @@ +import { Schema, model } from "mongoose"; + +import { IProductObserve } from "./Abstraction/IProductObserve"; + +const ProductObserveSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, required: true }, + product: { type: Number, required: true, ref: "Product" }, + variant: { type: Schema.Types.ObjectId, required: true, ref: "ProductVariant" }, + registeredPrice: { type: Number, required: true }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); + +const ProductObserveModel = model("ProductObserve", ProductObserveSchema); + +export { ProductObserveModel }; diff --git a/src/modules/product/models/productReport.model.ts b/src/modules/product/models/productReport.model.ts new file mode 100644 index 0000000..fb35311 --- /dev/null +++ b/src/modules/product/models/productReport.model.ts @@ -0,0 +1,19 @@ +import { Schema, model } from "mongoose"; + +import { IProductReport } from "./Abstraction/IProductReport"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const ProductReportSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + product: { type: Number, ref: "Product", required: true }, + reason: { type: [Number], ref: "ReportQuestion", required: true }, + description: { type: String, default: "" }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ProductReportModel = model("ProductReport", ProductReportSchema); + +export { ProductReportModel }; diff --git a/src/modules/product/models/productRequest.model.ts b/src/modules/product/models/productRequest.model.ts new file mode 100644 index 0000000..4d2b814 --- /dev/null +++ b/src/modules/product/models/productRequest.model.ts @@ -0,0 +1,40 @@ +import { Schema, model } from "mongoose"; + +import { IProductRequest } from "./Abstraction/IProductRequest"; +import { productImageSchema } from "./product.model"; +import { productDimensionsSchema } from "./productVariant.model"; +import { ProductSource, ProductStatus } from "../../../common/enums/product.enum"; + +const ProductRequestSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + brand: { type: Schema.Types.ObjectId, ref: "Brand", required: true }, + category: { type: Schema.Types.ObjectId, ref: "Category", required: true }, + source: { type: String, enum: ProductSource, default: ProductSource.LOCAL, required: true }, + productName: { type: String, required: true }, + // productType: { type: String, required: true }, + description: { type: String, required: true }, + dimensions: { type: productDimensionsSchema, required: true }, + advantages: { type: [String], default: [] }, + disAdvantages: { type: [String], default: [] }, + // + requestPhotography: { type: Boolean, default: false }, + requestPhotosCount: { type: Number, default: null }, + expertReview: { type: Boolean, default: false }, + unboxingVideo: { type: Boolean, default: false }, + imagesUrl: { type: productImageSchema }, + payment: { type: Schema.Types.ObjectId, ref: "PRPayment", default: null }, + requestStatus: { type: String, enum: ProductStatus, default: ProductStatus.Pending }, + adminComments: { type: String }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +const ProductRequestModel = model("ProductRequest", ProductRequestSchema); + +export { ProductRequestModel }; diff --git a/src/modules/product/models/productVariant.model.ts b/src/modules/product/models/productVariant.model.ts new file mode 100644 index 0000000..a632586 --- /dev/null +++ b/src/modules/product/models/productVariant.model.ts @@ -0,0 +1,118 @@ +import { Schema, model } from "mongoose"; + +import { IDimensions, IPrice, IProductVariant, ISaleFormat, IStatistics } from "./Abstraction/IProductVariant"; +import { ProductMarketStatus } from "../../../common/enums/product.enum"; + +const productStatisticsSchema = new Schema( + { + satisfied: { type: { rate: Number, count: Number } }, + dissatisfied: { type: { rate: Number, count: Number } }, + }, + { _id: false }, +); + +const productSaleFormat = new Schema( + { + // isWholeSale: Boolean, + wholeSale: { type: [{ from: Number, to: Number, price: Number }] }, + }, + { _id: false }, +); + +export const productDimensionsSchema = new Schema( + { + package_weight: Number, + package_height: Number, + package_length: Number, + package_width: Number, + }, + { _id: false }, +); + +const productPriceSchema = new Schema( + { + order_limit: { type: Number, required: true }, + retailPrice: { type: Number }, + selling_price: { type: Number }, + //specialSale + is_specialSale: { type: Boolean, default: false }, + discount_percent: { type: Number, default: 0 }, + specialSale_order_limit: { type: Number, default: null }, + specialSale_quantity: { type: Number, default: null }, + specialSale_endDate: { type: String, default: null }, + }, + { _id: false }, +); + +const productVariantSchema = new Schema( + { + product: { type: Number, ref: "Product", required: true }, + statistics: { type: productStatisticsSchema }, + warranty: { type: Number, ref: "Warranty", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + market_status: { type: String, enum: ProductMarketStatus, required: true }, + postingTime: { type: Number, required: true }, + price: { type: productPriceSchema, required: true }, + stock: { type: Number, required: true }, + isFreeShip: { type: Boolean, default: false }, + isWholeSale: { type: Boolean, default: false }, + saleFormat: { type: productSaleFormat }, + dimensions: { type: productDimensionsSchema, required: true }, + sellerSpecialCode: { type: String }, + color: { type: Number, ref: "Color", required: false }, + size: { type: Number, ref: "Size", required: false }, + meterage: { type: Number, ref: "Meterage", required: false }, + }, + { timestamps: true, toObject: { virtuals: true, versionKey: false }, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +productVariantSchema.pre("save", function (next) { + if (this.price) { + if (this.price.retailPrice) { + this.price.retailPrice = Math.round(this.price.retailPrice / 10000) * 10000; + const sellingPrice = + this.price.discount_percent > 0 ? this.price.retailPrice * ((100 - this.price.discount_percent) / 100) : this.price.retailPrice; + + this.price.selling_price = Math.round(sellingPrice / 10000) * 10000; + } + } + + next(); +}); + +productVariantSchema.pre(["findOneAndUpdate", "updateOne", "updateMany"], function (next) { + const update = this.getUpdate() as IProductVariant; + if (!update) return next(); + + // ensure price object exists in the update object + if (update.price && update.price.retailPrice !== undefined) { + const retailPrice = Math.round(update.price.retailPrice / 10000) * 10000; + const discountPercent = Math.round(update.price.discount_percent) || 0; + + // calculate selling price based on retailPrice and discount_percent + const sellingPrice = discountPercent > 0 ? retailPrice * ((100 - discountPercent) / 100) : retailPrice; + + // set the calculated selling price in the update object + update.price.retailPrice = retailPrice; + update.price.selling_price = Math.round(sellingPrice / 10000) * 10000; + update.price.discount_percent = discountPercent; + } + + next(); +}); + +const ProductVariantModel = model("ProductVariant", productVariantSchema); + +export { ProductVariantModel }; + +// const productSpecialSale = new Schema( +// { +// endDate: String, +// type: { type: String, enum: ProductDiscountType, required: true }, +// discountValue: Number, +// order_limit: Number, +// quantity: Number, +// priceAfterDiscount: Number, +// }, +// { _id: false }, +// ); diff --git a/src/modules/product/models/question.model.ts b/src/modules/product/models/question.model.ts new file mode 100644 index 0000000..c3c0a65 --- /dev/null +++ b/src/modules/product/models/question.model.ts @@ -0,0 +1,44 @@ +import { Schema, model } from "mongoose"; + +import { IQuestion } from "./Abstraction/IQuestion"; +import { Question_CommentStatus } from "../../../common/enums/question_comment.enum"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; + +const QuestionSchema = new Schema( + { + content: { type: String, required: true }, + status: { type: String, enum: Question_CommentStatus, default: Question_CommentStatus.Pending }, + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + product: { type: Number, ref: "Product", required: true }, + answers: [ + { + content: { type: String, required: true }, + owner: { type: Schema.Types.ObjectId, required: true, refPath: "ownerRef" }, + ownerRef: { type: String, required: true, enum: OwnerRef }, + }, + ], + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +QuestionSchema.pre(["find", "findOne"], function (next) { + this.populate({ path: "user" }); + next(); +}); + +QuestionSchema.pre(["find", "findOne"], function (next) { + this.populate({ + path: "product", + populate: ["brand", "category", "shop"], + select: { adminComments: 0, variants: 0 }, + }); + next(); +}); + +const QuestionModel = model("Question", QuestionSchema); + +export { QuestionModel }; diff --git a/src/modules/product/models/reportQuestion.model.ts b/src/modules/product/models/reportQuestion.model.ts new file mode 100644 index 0000000..c3de2d3 --- /dev/null +++ b/src/modules/product/models/reportQuestion.model.ts @@ -0,0 +1,22 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IReportQuestion, ReportQuestionType } from "./Abstraction/IReportQuestion"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const ReportQuestionSchema = new Schema( + { + _id: Number, + title: { type: String, required: true }, + type: { type: String, enum: ReportQuestionType, required: true }, + placeholder: { type: String, default: null }, + // related_questions: [Number], + }, + { timestamps: true, toJSON: { versionKey: false }, _id: false, id: false }, +); + +ReportQuestionSchema.plugin(AutoIncrement, { id: "reportQues_id", inc_field: "_id" }); +const ReportQuestionModel = model("ReportQuestion", ReportQuestionSchema); + +export { ReportQuestionModel }; diff --git a/src/modules/product/product.controller.ts b/src/modules/product/product.controller.ts new file mode 100644 index 0000000..965c2c5 --- /dev/null +++ b/src/modules/product/product.controller.ts @@ -0,0 +1,683 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { + controller, + httpDelete, + httpGet, + httpPatch, + httpPost, + queryParam, + request, + requestBody, + requestParam, +} from "inversify-express-utils"; + +import { ProductFinalStepDTO, ProductStepOneDTO, ProductStepTwoDTO } from "./DTO/CreateProduct.dto"; +import { ProductService } from "./providers/product.service"; +import { HttpStatus } from "../../common"; +import { ProductAdDTO } from "./DTO/addProductAd.dto"; +import { AddVariantDTO } from "./DTO/addVariant.dto"; +import { AddWishlistDTO, RemoveWishlistDTO } from "./DTO/addwishlist.dto"; +import { CreateAnswerQuestionDTO, CreateCommentDTO, CreateQuestionDTO } from "./DTO/createQuestion.dto"; +import { CreateReportDTO } from "./DTO/createReport.dto"; +import { AddObserverDTO, RemoveObserverDTO } from "./DTO/observer.dto"; +import { ProductParamDto, SetFreeShipParamDto } from "./DTO/productParam.dto"; +import { RequestProductDTO } from "./DTO/RequestProduct.dto"; +import { UpdateProductFinalStepDTO, UpdateProductStepOneDTO, UpdateProductStepTwoDTO } from "./DTO/updateProduct.dto"; +import { ActivateVariantDTO, UpdateVariantDTO } from "./DTO/updateVariant.dto"; +import { ProductRequestService } from "./providers/product-request.service"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { PaginationDTO } from "../../common/dto/pagination.dto"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/product") +@ApiTags("Product") +class ProductController extends BaseController { + @inject(IOCTYPES.ProductService) productService: ProductService; + @inject(IOCTYPES.ProductRequestService) productRequestService: ProductRequestService; + + //############################################################### + //############################################################### + //create product + @ApiOperation("first step to create a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepOneDTO) + @ApiAuth() + @httpPost("/creation/detail", Guard.authSeller(), ValidationMiddleware.validateInput(ProductStepOneDTO)) + public async createProduct(@requestBody() createProductStepOneDto: ProductStepOneDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.createProductS(createProductStepOneDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("second step to create a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductStepTwoDTO) + @ApiAuth() + @httpPost("/creation/attribute", Guard.authSeller(), ValidationMiddleware.validateInput(ProductStepTwoDTO)) + public async createProductStepTwo(@requestBody() createProductStepTwoDto: ProductStepTwoDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.createProductStepTwoS(createProductStepTwoDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("final step to create a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(ProductFinalStepDTO) + @ApiAuth() + @httpPost("/creation/save", Guard.authSeller(), ValidationMiddleware.validateInput(ProductFinalStepDTO)) + public async createProductFinalStep(@requestBody() createProductFinalStepDto: ProductFinalStepDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.createProductFinalStepS(createProductFinalStepDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + //delete product of seller if not approved + + @ApiOperation("mark a product as draft") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of product") + @ApiAuth() + @httpPost("/:id/draft", Guard.authSeller()) + public async draftProduct(@requestParam("id") id: string) { + const data = await this.productService.draft(+id); + return this.response(data); + } + + @ApiOperation("delete a product with its id ==> login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @ApiParam("id", "id of product", true) + @httpDelete("/:id/delete", Guard.authSeller(), ValidationMiddleware.validateParameter(ProductParamDto)) + public async deleteProduct(@request() req: Request, @requestParam() paramDto: ProductParamDto) { + const seller = req.user as ISeller; + const data = await this.productService.deleteProduct(seller._id.toString(), +paramDto.id); + return this.response(data); + } + + @ApiOperation("clone exist products ==> login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of a product", true) + @ApiAuth() + @httpPost("/:id/clone", Guard.authSeller(), ValidationMiddleware.validateParameter(ProductParamDto)) + public async cloneProduct(@request() req: Request, @requestParam() paramDto: ProductParamDto) { + const seller = req.user as ISeller; + const data = await this.productService.cloneProduct(seller._id.toString(), paramDto.id); + return this.response(data); + } + + //update product if it is in draft status + @ApiOperation("first step to update a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductStepOneDTO) + @ApiAuth() + @httpPost("/update/detail", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateProductStepOneDTO)) + public async updateProduct(@requestBody() updateProductStepOneDto: UpdateProductStepOneDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.updateProductStepOneS(updateProductStepOneDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("second step to update a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductStepTwoDTO) + @ApiAuth() + @httpPost("/update/attribute", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateProductStepTwoDTO)) + public async updateProductStepTwo(@requestBody() updateProductStepTwoDto: UpdateProductStepTwoDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.updateProductStepTwoS(updateProductStepTwoDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("final step to update a single product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateProductFinalStepDTO) + @ApiAuth() + @httpPost("/update/save", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateProductFinalStepDTO)) + public async updateProductFinalStep(@requestBody() updateProductFinalStepDto: UpdateProductFinalStepDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.updateProductFinalStepS(updateProductFinalStepDto, seller._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + // request to create a product by admin + @ApiOperation("request to add product by admin ===> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(RequestProductDTO) + @ApiAuth() + @httpPost("/request/addBy-admin", Guard.authSeller(), ValidationMiddleware.validateInput(RequestProductDTO)) + public async requestProduct(@requestBody() requestProductDto: RequestProductDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productRequestService.requestProductS(requestProductDto, seller._id.toString()); + return this.response(data, HttpStatus.Created); + } + + //############################################################### + //############################################################### + + @ApiOperation("check if product is in user wishlist") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "productId", true) + @ApiParam("variantId", "variantId", true) + @ApiAuth() + @httpGet("/:id/wishlist/:variantId", Guard.authUser()) + public async checkProductInWishlist( + @requestParam("id") productId: string, + @requestParam("variantId") variantId: string, + @request() req: Request, + ) { + const user = req.user as IUser; + const data = await this.productService.checkProductInWishlist(user._id.toString(), +productId, variantId); + return this.response(data); + } + + //product wishlist + @ApiOperation("add product to the user wishlist ===> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "productId", true) + @ApiModel(AddWishlistDTO) + @ApiAuth() + @httpPost("/:id/wishlist/add", Guard.authUser(), ValidationMiddleware.validateInput(AddWishlistDTO)) + public async addToWishlist(@requestParam("id") productId: string, @request() req: Request, @requestBody() addDto: AddWishlistDTO) { + const user = req.user as IUser; + const data = await this.productService.addToWishlist(user._id.toString(), +productId, addDto); + return this.response(data); + } + + @ApiOperation("remove product from the user wishlist ===> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "productId", true) + @ApiModel(RemoveWishlistDTO) + @ApiAuth() + @httpPost("/:id/wishlist/remove", Guard.authUser(), ValidationMiddleware.validateInput(RemoveWishlistDTO)) + public async removeWishlist(@requestParam("id") productId: string, @request() req: Request, @requestBody() removeDto: RemoveWishlistDTO) { + const user = req.user as IUser; + const data = await this.productService.removeWishlist(user._id.toString(), +productId, removeDto); + return this.response(data); + } + + //############################################################### + //############################################################### + //product observe + @ApiOperation("add user to the product observer ===> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "productId", true) + @ApiModel(AddObserverDTO) + @ApiAuth() + @httpPost("/:id/observe/add", Guard.authUser(), ValidationMiddleware.validateInput(AddObserverDTO)) + public async addObserverToProduct( + @requestParam("id") productId: string, + @request() req: Request, + @requestBody() addObserverDto: AddObserverDTO, + ) { + const user = req.user as IUser; + const data = await this.productService.addObserver(user._id.toString(), +productId, addObserverDto); + return this.response(data); + } + + @ApiOperation("remove user from the product observer ===> need to login as user") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "productId", true) + @ApiModel(RemoveObserverDTO) + @ApiAuth() + @httpPost("/:id/observe/remove", Guard.authUser(), ValidationMiddleware.validateInput(RemoveObserverDTO)) + public async removeObserverFromProduct( + @requestParam("id") productId: string, + @request() req: Request, + @requestBody() removeObserverDto: RemoveObserverDTO, + ) { + const user = req.user as IUser; + const data = await this.productService.removeObserver(user._id.toString(), +productId, removeObserverDto); + return this.response(data); + } + + //############################################################### + //############################################################### + //product search ad + @ApiOperation("add product search ad ===> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiModel(ProductAdDTO) + @ApiAuth() + @httpPost("/:id/search-ad", Guard.authSeller(), ValidationMiddleware.validateInput(ProductAdDTO)) + public async addProductAd(@requestBody() createDto: ProductAdDTO, @request() req: Request, @requestParam("id") productId: string) { + const seller = req.user as ISeller; + const data = await this.productService.addProductAdS(seller._id.toString(), createDto, +productId); + return this.response(data, HttpStatus.Created); + } + @ApiOperation("get a product search ad ===> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiParam("variantId", "id of product variant", true) + @ApiAuth() + @httpGet("/:id/search-ad/:variantId", Guard.authSeller()) + public async getProductAd(@request() req: Request, @requestParam("id") productId: string, @requestParam("variantId") variantId: string) { + const seller = req.user as ISeller; + const data = await this.productService.getProductAds(seller._id.toString(), +productId, variantId); + return this.response(data); + } + //############################################################### + //############################################################### + //product variant + + @ApiOperation("add a variant to product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(AddVariantDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPost("/:id/variants", Guard.authSeller(), ValidationMiddleware.validateInput(AddVariantDTO)) + public async addVariant(@requestBody() addVariantDto: AddVariantDTO, @request() req: Request, @requestParam("id") productId: string) { + const seller = req.user as ISeller; + const data = await this.productService.addVariantS(addVariantDto, seller._id.toString(), +productId); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("Activate/Deactivate a product variant status ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiModel(ActivateVariantDTO) + @ApiAuth() + @httpPost("/:id/variants/activate", Guard.authSeller(), ValidationMiddleware.validateInput(ActivateVariantDTO)) + public async activateVariant( + @requestBody() activateDto: ActivateVariantDTO, + @request() req: Request, + @requestParam("id") productId: string, + ) { + const seller = req.user as ISeller; + const data = await this.productService.activateVariantS(seller._id.toString(), activateDto, +productId); + return this.response(data); + } + + @ApiOperation("set free shipping for a product variant ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiParam("variantId", "id of product variant", true) + @ApiAuth() + @httpPost("/:id/variants/:variantId/free-shipping", Guard.authSeller(), ValidationMiddleware.validateParameter(SetFreeShipParamDto)) + public async setFreeShipping(@queryParam() paramDto: SetFreeShipParamDto, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productService.setFreeShipping(seller._id.toString(), paramDto.id, paramDto.variantId); + return this.response(data); + } + + @ApiOperation("update a variant of product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Created) + @ApiModel(UpdateVariantDTO) + @ApiParam("id", "id of product", true) + @ApiAuth() + @httpPatch("/:id/variants", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateVariantDTO)) + public async updateVariant( + @requestBody() updateVariantDto: UpdateVariantDTO, + @request() req: Request, + @requestParam("id") productId: string, + ) { + const seller = req.user as ISeller; + const data = await this.productService.updateVariantS(updateVariantDto, seller._id.toString(), +productId); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("get all variants of a product ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiQuery("page", "the page want to get") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("variant_status", "status of variant ==> value = 1 if want to include those with this status") + @ApiQuery("shipment_method", "shipment method id of product variant") + @ApiQuery("stock", "stock status of variant ==> value 1 if want to include those with this status") + @ApiQuery("include_ad", "include_ad status ==> value = 1 if want to include those with this status ") + @ApiQuery("special_sale", "special_sale status ==> value = 1 if want to include those with this status") + @ApiQuery("sort", "sort variant ==> value = createdAt", false, "array") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiAuth() + @httpGet("/:id/variants", Guard.authSeller()) + public async getAllVariant( + @request() req: Request, + @requestParam("id") productId: string, + @queryParam("page") page: string, + @queryParam("limit") limit: string, + @queryParam("variant_status") variantStatus: string, + @queryParam("shipment_method") shipmentMethod: string, + @queryParam("stock") stock: string, + @queryParam("include_ad") includeAd: string, + @queryParam("special_sale") specialSale: string, + @queryParam("sort") sort: string[], + @queryParam("q") q: string, + ) { + const queries = { + page: parseInt(page), + limit: parseInt(limit), + variantStatus: !!variantStatus, + shipmentMethod: +shipmentMethod, + stock: !!stock, + includeAd: !!includeAd, + specialSale: !!specialSale, + sort, + q, + }; + const seller = req.user as ISeller; + const { count, ...data } = await this.productService.getAllVariantS(seller._id.toString(), +productId, queries); + const { pager } = this.paginate(count); + return this.response({ pager, productVariants: data.productVariants }); + } + + @ApiOperation("get all variants ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("page", "the page want to get") + @ApiQuery("limit", "the limit of return data") + @ApiQuery("variant_status", "status of variant ==> value = 1 if want to include those with this status") + @ApiQuery("shipment_method", "shipment method id of product variant") + @ApiQuery("stock", "stock status of variant ==> value 1 if want to include those with this status") + @ApiQuery("include_ad", "include_ad status ==> value = 1 if want to include those with this status ") + @ApiQuery("special_sale", "special_sale status ==> value = 1 if want to include those with this status") + @ApiQuery("sort", "sort variant ==> value = createdAt", false, "array") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiAuth() + @httpGet("/variants", Guard.authSeller()) + public async getAllVariants( + @request() req: Request, + @queryParam("page") page: string, + @queryParam("limit") limit: string, + @queryParam("variant_status") variantStatus: string, + @queryParam("shipment_method") shipmentMethod: string, + @queryParam("stock") stock: string, + @queryParam("include_ad") includeAd: string, + @queryParam("special_sale") specialSale: string, + @queryParam("sort") sort: string[], + @queryParam("q") q: string, + ) { + const queries = { + page: parseInt(page), + limit: parseInt(limit), + variantStatus: !!variantStatus, + shipmentMethod: +shipmentMethod, + stock: !!stock, + includeAd: !!includeAd, + specialSale: !!specialSale, + sort, + q, + }; + const seller = req.user as ISeller; + const { count, ...data } = await this.productService.getAllProductsVariantsS(seller._id.toString(), queries); + const { pager } = this.paginate(count); + return this.response({ pager, productVariants: data.productsVariants }); + } + /** */ + @ApiOperation("get a single product variant ==> need to login as seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @ApiParam("variantId", "id of product variant", true) + @ApiAuth() + @httpGet("/:id/variants/:variantId", Guard.authSeller()) + public async getSingleVariant( + @request() req: Request, + @requestParam("id") productId: string, + @requestParam("variantId") variantId: string, + ) { + const seller = req.user as ISeller; + const data = await this.productService.getSingleVariant(seller._id.toString(), +productId, variantId); + return this.response(data); + } + + //############################################################### + //############################################################### + //compare product + @ApiOperation("search to get all product to compare based on the product category") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("productId", "id of a product", true) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @httpGet("/compare/search") + public async searchForCompareProducts( + @queryParam("productId") productId: string, + @queryParam("limit") limit: string, + @queryParam("page") page: string, + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + productId: +productId, + }; + + const { count, ...data } = await this.productService.searchForCompareProductsS(queries); + const { pager } = this.paginate(count); + return this.response({ pager, products: data.products }); + } + + @ApiOperation("get product specification to compare") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("productIds", "Array of product IDs to compare", true, "array") + @httpGet("/compare") + public async compareProducts(@queryParam("productIds") productIds: string[]) { + const data = await this.productService.compareProducts(productIds); + return this.response(data); + } + + //############################################################### + //############################################################### + // get product and delete product + + @ApiOperation("search the product with query") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("q", "search query", true) + @httpGet("/search") + public async productSearch(@queryParam("q") q: string) { + const data = await this.productService.searchProducts(q); + return this.response(data); + } + + @ApiOperation("get product details for product page") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("productId", "id of a product", true) + @httpGet("/:productId") + public async getProductDetails(@requestParam("productId") productId: string) { + const data = await this.productService.getProductDetails(+productId); + return this.response(data); + } + + @ApiOperation("get product price-history") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("productId", "id of a product", true) + @ApiQuery("since", "search since date ==> 1403/01/15") + @httpGet("/:productId/price-history") + public async getProductPriceHistory(@requestParam("productId") productId: string, @queryParam("since") since: string) { + const data = await this.productService.getProductPriceHistoryS(+productId, since); + return this.response(data); + } + + //############################################################### + //############################################################### + //product questions and comments + @ApiOperation("add a question to a product with its id ====> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiModel(CreateQuestionDTO) + @ApiAuth() + @httpPost("/:id/questions", Guard.authUser(), ValidationMiddleware.validateInput(CreateQuestionDTO)) + public async addQuestion( + @request() req: Request, + @requestParam("id") productId: string, + @requestBody() createQuestionDto: CreateQuestionDTO, + ) { + const user = req.user as IUser; + const data = await this.productService.addQuestionS(user._id.toString(), +productId, createQuestionDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all questions of a product with its id") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @httpGet("/:id/questions") + public async getQuestions(@requestParam("id") productId: string) { + const data = await this.productService.getQuestions(+productId); + return this.response(data); + } + + @ApiOperation("delete a question with its id ====> need to login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of question") + @ApiAuth() + @httpDelete("/questions/:id/delete") + public async deleteQuestion(@requestParam("id") id: string) { + const data = await this.productService.deleteProductQuestion(id); + return this.response(data); + } + + @ApiOperation("delete a comment with its id ====> need to login as admin") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of comment") + @ApiAuth() + @httpDelete("/comments/:id/delete") + public async deleteComment(@requestParam("id") id: string) { + const data = await this.productService.deleteProductComment(id); + return this.response(data); + } + + @ApiOperation("answer to a question with its id ====> need to login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of question") + @ApiModel(CreateAnswerQuestionDTO) + @ApiAuth() + @httpPost("/questions/:id/answer", Guard.authSeller(), ValidationMiddleware.validateInput(CreateAnswerQuestionDTO)) + public async answerQuestion(@requestParam("id") id: string, @requestBody() createDto: CreateAnswerQuestionDTO, @request() req: Request) { + const user = req.user as ISeller; + const data = await this.productService.answerQuestion(id, createDto, user._id.toString(), OwnerRef.SELLER); + return this.response(data); + } + + @ApiOperation("add a comment to a product with its id ====> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiModel(CreateCommentDTO) + @ApiAuth() + @httpPost("/:id/comments", Guard.authUser(), ValidationMiddleware.validateInput(CreateCommentDTO)) + public async addComment( + @request() req: Request, + @requestParam("id") productId: string, + @requestBody() createCommentDto: CreateCommentDTO, + ) { + const user = req.user as IUser; + const data = await this.productService.addCommentS(user._id.toString(), +productId, createCommentDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all comments of a product with its id") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @httpGet("/:id/comments") + public async getComments(@requestParam("id") productId: string) { + const data = await this.productService.getComments(+productId); + return this.response(data); + } + + //############################################################### + //############################################################### + //product report + @ApiOperation("get a report question of a product") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiParam("id", "id of product", true) + @httpGet("/:id/report/questions") + public async getReportQuestion() { + const data = await this.productService.getReportQuestions(); + return this.response(data); + } + + @ApiOperation("create a report on a product ====> need to login as user") + @ApiResponse("successfully", HttpStatus.Created) + @ApiParam("id", "id of product", true) + @ApiModel(CreateReportDTO) + @ApiAuth() + @httpPost("/:id/report/save", Guard.authUser(), ValidationMiddleware.validateInput(CreateReportDTO)) + public async addReport(@request() req: Request, @requestBody() createReportDto: CreateReportDTO, @requestParam("id") productId: string) { + const user = req.user as IUser; + const data = await this.productService.addReportS(user._id.toString(), createReportDto, +productId); + return this.response(data, HttpStatus.Created); + } + + //############################################################### + //############################################################### + //popular & incredible product + @ApiOperation("popular products") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiAuth() + @httpGet("/popular/all", Guard.authOptional()) + public async getPopularProducts(@request() req: Request) { + const user = req.user as IUser; + const data = await this.productService.getPopularProducts(user?._id.toString()); + return this.response(data, HttpStatus.Ok); + } + + @ApiOperation("get all incredible offer that added for admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("page", "the page want to get") + @ApiQuery("limit", "the limit of return data") + @ApiAuth() + @httpGet("/incredible/all", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getIncredibleProducts(@queryParam() paginationDto: PaginationDTO) { + const { count, incredibleOffers } = await this.productService.getIncredibleOffers(paginationDto); + const { pager } = this.paginate(count); + return this.response({ pager, incredibleOffers }, HttpStatus.Ok); + } + + @ApiOperation("get all special sales product for admin") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("page", "the page want to get") + @ApiQuery("limit", "the limit of return data") + @ApiAuth() + @httpGet("/special-sale/all", Guard.authAdmin(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getAllProductWithSpecialSale(@queryParam() paginationDto: PaginationDTO) { + const { count, specialSaleProducts } = await this.productService.getSpecialProductsForAdmin(paginationDto); + const { pager } = this.paginate(count); + return this.response({ pager, specialSaleProducts }, HttpStatus.Ok); + } + + //############################################################### + //############################################################### + //product uploader + @ApiOperation("Upload a product image ==> need to login as seller") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload/single", Guard.authSeller(), UploadService.single("image", "product-image")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } + + @ApiOperation("Upload a multiple product images ==> need to login as seller") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image", true, true) + @ApiAuth() + @httpPost("/image/upload/multiple", Guard.authSeller(), UploadService.multiple("image", 10, "product-image")) + public async uploadImages(@request() req: Request) { + // eslint-disable-next-line no-undef + const files = req.files as Express.MulterS3.File[]; + const data = { + message: "files uploaded!", + urls: files.map((file) => ({ + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + })), + }; + return this.response(data, HttpStatus.Accepted); + } +} + +export { ProductController }; diff --git a/src/modules/product/providers/product-request.service.ts b/src/modules/product/providers/product-request.service.ts new file mode 100644 index 0000000..4a6c7d0 --- /dev/null +++ b/src/modules/product/providers/product-request.service.ts @@ -0,0 +1,117 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId, startSession } from "mongoose"; + +import { CommonMessage, ProductMessage, ProductRequestMessage, ShopMessage } from "../../../common/enums/message.enum"; +import { BadRequestError } from "../../../core/app/app.errors"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../../shop/shop.repository"; +import { RequestProductDTO } from "../DTO/RequestProduct.dto"; +import { IDimensions } from "../models/Abstraction/IProductVariant"; +import { ProductRequestRepo } from "../Repository/productRequest"; + +@injectable() +export class ProductRequestService { + @inject(IOCTYPES.ProductRequestRepo) productRequestRepo: ProductRequestRepo; + @inject(IOCTYPES.ShopRepo) private readonly shopRepo: ShopRepo; + + //############################# + + async requestProductS(requestProductDto: RequestProductDTO, sellerId: string) { + const session = await startSession(); + session.startTransaction(); + try { + if (requestProductDto.requestPhotography && (!requestProductDto.requestPhotosCount || requestProductDto.requestPhotosCount <= 0)) { + throw new BadRequestError(ProductRequestMessage.RequestPhotoInvalid); + } + + if (!requestProductDto.requestPhotography && (!requestProductDto.coverImage || requestProductDto.coverImage.trim() === "")) { + throw new BadRequestError(ProductRequestMessage.PhotoShouldSent); + } + + const shop = await this.shopRepo.model.findOne({ ownerRef: OwnerRef.SELLER, owner: sellerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const dimensions: IDimensions = { + package_weight: requestProductDto.package_weight, + package_height: requestProductDto.package_height, + package_length: requestProductDto.package_length, + package_width: requestProductDto.package_width, + }; + const imagesUrl = { cover: requestProductDto.coverImage, list: requestProductDto.imagesList }; + + const newProductRequest = await this.productRequestRepo.model.create( + [ + { + seller: sellerId, + shop: shop._id, + imagesUrl, + dimensions, + ...requestProductDto, + }, + ], + { session }, + ); + + await session.commitTransaction(); + + return { + message: ProductMessage.ProductRequested, + newProductRequest: newProductRequest[0], + }; + } catch (error) { + await session.abortTransaction(); + + throw error; + } finally { + await session.endSession(); + } + } + //############################# + + async getProductRequests(sellerId: string) { + const productRequests = await this.productRequestRepo.model + .find({ seller: sellerId }) + .populate([ + { path: "brand", select: { _id: 1, title_fa: 1, title_en: 1 } }, + { path: "category", select: { _id: 1, title_fa: 1, title_en: 1 } }, + { path: "payment" }, + ]); + return { + productRequests, + }; + } + //############################# + + async getProductRequestForAdmin() { + const productRequests = await this.productRequestRepo.model + .find({}) + .populate([ + { path: "seller", select: { _id: 1, fullName: 1 } }, + { path: "shop", select: { _id: 1, shopCode: 1, shopName: 1 } }, + { path: "brand", select: { _id: 1, title_fa: 1, title_en: 1 } }, + { path: "category", select: { children: 0, variant: 0 } }, + { path: "payment" }, + ]); + + return { + productRequests, + }; + } + //############################# + + async getProductRequestDetails(sellerId: string, requestId: string) { + if (!isValidObjectId(requestId)) throw new BadRequestError(CommonMessage.NotValidId); + const productRequest = await this.productRequestRepo.model + .findOne({ _id: requestId, seller: sellerId }) + .populate([ + { path: "brand", select: { _id: 1, title_fa: 1, title_en: 1 } }, + { path: "category", select: { _id: 1, title_fa: 1, title_en: 1 } }, + { path: "payment" }, + ]); + if (!productRequest) throw new BadRequestError(CommonMessage.NotValidId); + return { + productRequest, + }; + } +} diff --git a/src/modules/product/providers/product.service.ts b/src/modules/product/providers/product.service.ts new file mode 100644 index 0000000..a9135a8 --- /dev/null +++ b/src/modules/product/providers/product.service.ts @@ -0,0 +1,1728 @@ +import { inject, injectable } from "inversify"; +import { ClientSession, FilterQuery, isValidObjectId, startSession } from "mongoose"; +import slugify from "slugify"; + +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { CategoryThemeEnum } from "../../../common/enums/category.enum"; +import { + AdMessage, + AttributeMessage, + CartMessage, + CategoryMessage, + CommonMessage, + ProductMessage, + ShopMessage, +} from "../../../common/enums/message.enum"; +import { CreateProductStep, ProductDiscountType, ProductMarketStatus, ProductStatus } from "../../../common/enums/product.enum"; +import { Question_CommentStatus } from "../../../common/enums/question_comment.enum"; +import { + CompareSearchQueries, + FilterByStatusProductsQueries, + ProductsCommentsQueries, + ProductsQuestionsQueries, + VariantQueries, +} from "../../../common/types/query.type"; +import { BadRequestError, ForbiddenError } from "../../../core/app/app.errors"; +import { PriceChangeEvent } from "../../../events/priceChange"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { paginationUtils } from "../../../utils/pagination.utils"; +import { TimeService } from "../../../utils/time.service"; +import { AddIncredibleOffersParamDto } from "../../admin/DTO/product-param.dto"; +import { BrandDTO } from "../../brand/DTO/brand.dto"; +import { AttributeValueRepo, CategoryAttributeRepo, CategoryRepository } from "../../category/category.repository"; +import { CategoryTreeDTO } from "../../category/DTO/category.dto"; +import { IAttributeValue } from "../../category/models/Abstraction/IAttributeValue"; +import { NotificationService } from "../../notification/notification.service"; +import { PricingRepository } from "../../pricing/pricing.repository"; +import { SellerProductQueryDTO } from "../../seller/DTO/sellerProductQuery.dto"; +import { SellerRepository } from "../../seller/seller.repository"; +import { OwnerRef } from "../../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../../shop/shop.repository"; +import { CommentDTO } from "../../user/DTO/comment.dto"; +import { QuestionDTO } from "../../user/DTO/question.dto"; +import { WishlistRepo } from "../../user/user.repository"; +import { ProductAdDTO } from "../DTO/addProductAd.dto"; +import { AddVariantDTO } from "../DTO/addVariant.dto"; +import { AddWishlistDTO, RemoveWishlistDTO } from "../DTO/addwishlist.dto"; +import { ProductFinalStepDTO, ProductStepOneDTO, ProductStepTwoDTO } from "../DTO/CreateProduct.dto"; +import { CreateAnswerQuestionDTO, CreateCommentDTO, CreateQuestionDTO } from "../DTO/createQuestion.dto"; +import { CreateReportDTO } from "../DTO/createReport.dto"; +import { CreateReportQuestionDTO } from "../DTO/createReportQuestion.dto"; +import { AddObserverDTO, RemoveObserverDTO } from "../DTO/observer.dto"; +import { ProductDTO, ProductVariantDTO, RejectCommentDTO, SellerPanelProductDTO } from "../DTO/product.dto"; +import { + AddVoiceToProductDTO, + UpdateProductDTO, + UpdateProductFinalStepDTO, + UpdateProductStepOneDTO, + UpdateProductStepTwoDTO, +} from "../DTO/updateProduct.dto"; +import { ActivateVariantDTO, UpdateVariantDTO } from "../DTO/updateVariant.dto"; +import { IComment } from "../models/Abstraction/IComment"; +import { IProduct, ISpecifications } from "../models/Abstraction/IProduct"; +import { AdType, IProductAd } from "../models/Abstraction/IProductAd"; +import { IDimensions, IPrice, IProductVariant } from "../models/Abstraction/IProductVariant"; +import { IQuestion } from "../models/Abstraction/IQuestion"; +import { ReportQuestionType } from "../models/Abstraction/IReportQuestion"; +import { CommentRepository } from "../Repository/comment"; +import { IncredibleOffersRepo } from "../Repository/incredibleOffers"; +import { PopularProductRepo } from "../Repository/popularProduct"; +import { PriceHistoryRepo } from "../Repository/pricehistory"; +import { ProductRepository } from "../Repository/product"; +import { ProductAdRepo } from "../Repository/productAd"; +import { ProductObserveRepo } from "../Repository/productObserve"; +import { ProductReportRepo } from "../Repository/productReport"; +import { ProductVariantRepository } from "../Repository/productVarinat"; +import { QuestionRepository } from "../Repository/question"; +import { ReportQuestionRepo } from "../Repository/reportQuestion"; + +@injectable() +class ProductService { + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.PricingRepo) pricingRepo: PricingRepository; + @inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository; + @inject(IOCTYPES.CategoryRepository) categoryRepo: CategoryRepository; + @inject(IOCTYPES.CategoryAttributeRepository) categoryAttRepo: CategoryAttributeRepo; + @inject(IOCTYPES.AttributeValueRepository) attributeValueRepo: AttributeValueRepo; + @inject(IOCTYPES.QuestionRepository) questionRepo: QuestionRepository; + @inject(IOCTYPES.CommentRepository) commentRepo: CommentRepository; + @inject(IOCTYPES.ReportQuestionRepo) reportQuestionRepo: ReportQuestionRepo; + @inject(IOCTYPES.ProductReportRepo) productReportRepo: ProductReportRepo; + @inject(IOCTYPES.PriceHistoryRepo) priceHistoryRepo: PriceHistoryRepo; + @inject(IOCTYPES.ProductAdRepo) productAdRepo: ProductAdRepo; + @inject(IOCTYPES.PopularProductRepo) popularProductRepo: PopularProductRepo; + @inject(IOCTYPES.ProductObserveRepo) productObserveRepo: ProductObserveRepo; + @inject(IOCTYPES.WishlistRepo) wishlistRepo: WishlistRepo; + @inject(IOCTYPES.ShopRepo) shopeRepo: ShopRepo; + @inject(IOCTYPES.IncredibleOffersRepo) incredibleOffersRepo: IncredibleOffersRepo; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + + //############################################################### + async searchProducts(q: string) { + const products = await this.productRepo.model.find( + { + $or: [ + { title_en: { $regex: q, $options: "i" } }, + { title_fa: { $regex: q, $options: "i" } }, + { model: { $regex: q, $options: "i" } }, + ], + variants: { $elemMatch: { $exists: true } }, + }, + { _id: 1, title_fa: 1, title_en: 1, imagesUrl: 1, model: 1, description: 1, variants: 1 }, + ); + + return { products }; + } + + //############################################################### + //################# product creation and crud variant ########### + async createProductS(createStepOneDto: ProductStepOneDTO, ownerId: string) { + await this.checkForDuplicateProduct(createStepOneDto.model); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const createdProduct = await this.productRepo.model.create({ + ...createStepOneDto, + title_en: createStepOneDto.title_en ?? slugify(createStepOneDto.title_fa), + shop: shop._id, + }); + return { + message: ProductMessage.Created, + draftProduct: this.mapToDraftProductDTO(createdProduct, "attribute"), + }; + } + //************************************************************** + //************************************************************** + async createProductStepTwoS(createStepTwoDto: ProductStepTwoDTO, ownerId: string) { + //TODO:check if attribute belong to the category of product + const session = await startSession(); + session.startTransaction(); + + try { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(createStepTwoDto.productId, ownerId); + + // Ensure the step is correct + this.ensureCorrectStep(product.step, CreateProductStep.Attribute); + + // Initialize an array to hold the specifications + const specifications: ISpecifications[] = []; + + // Loop through each attribute in the DTO + for (const attribute of createStepTwoDto.attributes) { + const attributeDetails = await this.categoryAttRepo.model.findById(attribute.id); + + if (!attributeDetails) { + throw new BadRequestError(AttributeMessage.AttributeIdIsIncorrect); + } + + // Fetch the attribute values from the database + const attributeValues = await this.attributeValueRepo.model.find({ + _id: { $in: attribute.values }, + }); + + // Check if any attribute values were found + if (attributeValues.length === 0) { + throw new BadRequestError(`No valid attribute values found for attribute id ${attribute.id}`); + } + + // Map the values to their text value + const selectedValues = attributeValues.map((val: IAttributeValue) => val.text); + + specifications.push({ + title: attributeDetails.title, + values: selectedValues, + }); + } + + // Update the product with the new specifications and other fields + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + createStepTwoDto.productId, + { + specifications, + description: createStepTwoDto.description, + metaDescription: createStepTwoDto.metaDescription, + tags: createStepTwoDto.tags, + advantages: createStepTwoDto.advantages, + disAdvantages: createStepTwoDto.disAdvantages, + step: CreateProductStep.Attribute, + }, + { new: true, session }, + ); + + await session.commitTransaction(); + await session.endSession(); + + return { + message: ProductMessage.CreatedAttribute, + draftProduct: this.mapToDraftProductDTO(updatedProduct as IProduct, "image"), + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //************************************************************** + //************************************************************** + async createProductFinalStepS(createFinalStepDto: ProductFinalStepDTO, ownerId: string) { + const product = await this.validateProduct(createFinalStepDto.productId, ownerId); + + this.ensureCorrectStep(product.step, CreateProductStep.Image); + + const imagesUrl = { cover: createFinalStepDto.coverImage, list: createFinalStepDto.imagesList }; + + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + createFinalStepDto.productId, + { + imagesUrl, + step: CreateProductStep.Image, + status: ProductStatus.Pending, + }, + { new: true }, + ); + + return { + message: ProductMessage.ProductReadyForRelease, + updatedProduct, + }; + } + + //############################################################### + //################# product creation and crud variant ########### + async createProductForShopS(createStepOneDto: ProductStepOneDTO, shopId: string) { + await this.checkForDuplicateProduct(createStepOneDto.model); + + const shop = await this.shopeRepo.model.findById(shopId); + console.log({ shop, shopId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const createdProduct = await this.productRepo.model.create({ + ...createStepOneDto, + title_en: createStepOneDto.title_en ?? slugify(createStepOneDto.title_fa), + shop: shop._id, + }); + return { + message: ProductMessage.Created, + draftProduct: this.mapToDraftProductDTO(createdProduct, "attribute"), + }; + } + //************************************************************** + //************************************************************** + async createProductStepTwoForShopS(createStepTwoDto: ProductStepTwoDTO, shopId: string) { + //TODO:check if attribute belong to the category of product + const session = await startSession(); + session.startTransaction(); + + try { + const shop = await this.shopeRepo.model.findById(shopId); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(createStepTwoDto.productId, shop.owner.toString()); + + // Ensure the step is correct + this.ensureCorrectStep(product.step, CreateProductStep.Attribute); + + // Initialize an array to hold the specifications + const specifications: ISpecifications[] = []; + + // Loop through each attribute in the DTO + for (const attribute of createStepTwoDto.attributes) { + const attributeDetails = await this.categoryAttRepo.model.findById(attribute.id); + + if (!attributeDetails) { + throw new BadRequestError(AttributeMessage.AttributeIdIsIncorrect); + } + + // Fetch the attribute values from the database + const attributeValues = await this.attributeValueRepo.model.find({ + _id: { $in: attribute.values }, + }); + + // Check if any attribute values were found + if (attributeValues.length === 0) { + throw new BadRequestError(`No valid attribute values found for attribute id ${attribute.id}`); + } + + // Map the values to their text value + const selectedValues = attributeValues.map((val: IAttributeValue) => val.text); + + specifications.push({ + title: attributeDetails.title, + values: selectedValues, + }); + } + + // Update the product with the new specifications and other fields + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + createStepTwoDto.productId, + { + specifications, + description: createStepTwoDto.description, + metaDescription: createStepTwoDto.metaDescription, + tags: createStepTwoDto.tags, + advantages: createStepTwoDto.advantages, + disAdvantages: createStepTwoDto.disAdvantages, + step: CreateProductStep.Attribute, + }, + { new: true, session }, + ); + + await session.commitTransaction(); + await session.endSession(); + + return { + message: ProductMessage.CreatedAttribute, + draftProduct: this.mapToDraftProductDTO(updatedProduct as IProduct, "image"), + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //************************************************************** + //************************************************************** + async createProductFinalStepForShopS(createFinalStepDto: ProductFinalStepDTO, shopId: string) { + const shop = await this.shopeRepo.model.findById(shopId); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(createFinalStepDto.productId, shop.owner.toString()); + + this.ensureCorrectStep(product.step, CreateProductStep.Image); + + const imagesUrl = { cover: createFinalStepDto.coverImage, list: createFinalStepDto.imagesList }; + + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + createFinalStepDto.productId, + { + imagesUrl, + step: CreateProductStep.Image, + status: ProductStatus.Approved, + }, + { new: true }, + ); + if (!updatedProduct) { + throw new BadRequestError(ProductMessage.CanNotUpdateProduct); + } + await this.notificationService.notifyNewProduct(shop.owner.toString(), updatedProduct.model); + return { + message: ProductMessage.ProductReadyForRelease, + updatedProduct, + }; + } + + //#####################product update######################### + //############################################################ + + async updateProductStepOneS(updateStepOneDto: UpdateProductStepOneDTO, ownerId: string) { + const session = await startSession(); + session.startTransaction(); + + try { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(updateStepOneDto.productId, ownerId); + + // ensure the product is in draft status + this.ensureDraftStatus(product.status); + + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + updateStepOneDto.productId, + { + ...updateStepOneDto, + // step: CreateProductStep.Attribute, + }, + { new: true, session }, + ); + + await session.commitTransaction(); + await session.endSession(); + + return { + message: ProductMessage.ProductUpdated, + draftProduct: this.mapToDraftProductDTO(updatedProduct as IProduct, "attribute"), + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + //************************************************************** + //************************************************************** + + async updateProductStepTwoS(updateStepTwoDto: UpdateProductStepTwoDTO, ownerId: string) { + const session = await startSession(); + session.startTransaction(); + + try { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(updateStepTwoDto.productId, ownerId); + + // ensure the product is in draft status + this.ensureDraftStatus(product.status); + + // ensure the step is correct + // this.ensureCorrectStep(product.step, CreateProductStep.Attribute); + + // update specifications as in the creation step + const specifications: ISpecifications[] = []; + for (const attribute of updateStepTwoDto.attributes || []) { + const attributeDetails = await this.categoryAttRepo.model.findById(attribute.id); + + if (!attributeDetails) { + throw new BadRequestError(AttributeMessage.AttributeIdIsIncorrect); + } + + const attributeValues = await this.attributeValueRepo.model.find({ + _id: { $in: attribute.values }, + }); + + if (attributeValues.length === 0) { + throw new BadRequestError(`No valid attribute values found for attribute id ${attribute.id}`); + } + + const selectedValues = attributeValues.map((val: IAttributeValue) => val.text); + + specifications.push({ + title: attributeDetails.title, + values: selectedValues, + }); + } + + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + updateStepTwoDto.productId, + { + specifications, + description: updateStepTwoDto.description, + metaDescription: updateStepTwoDto.metaDescription, + tags: updateStepTwoDto.tags, + advantages: updateStepTwoDto.advantages, + disAdvantages: updateStepTwoDto.disAdvantages, + }, + { new: true, session }, + ); + + await session.commitTransaction(); + await session.endSession(); + + return { + message: ProductMessage.ProductUpdated, + draftProduct: this.mapToDraftProductDTO(updatedProduct as IProduct, "image"), + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //************************************************************** + //************************************************************** + + async updateProductFinalStepS(updateFinalStepDto: UpdateProductFinalStepDTO, ownerId: string) { + const product = await this.validateProduct(updateFinalStepDto.productId, ownerId); + + // ensure the product is in draft status + this.ensureDraftStatus(product.status); + + // ensure the step is correct + // this.ensureCorrectStep(product.step, CreateProductStep.Image); + + const imagesUrl = { cover: updateFinalStepDto.coverImage, list: updateFinalStepDto.imagesList }; + + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + updateFinalStepDto.productId, + { + imagesUrl, + // step: CreateProductStep.Image, + // status: ProductStatus.Pending, + }, + { new: true }, + ); + + return { + message: ProductMessage.ProductUpdated, + updatedProduct, + }; + } + + async addVoiceToProduct(productId: number, addVoiceDto: AddVoiceToProductDTO) { + const product = await this.productRepo.model.findByIdAndUpdate(productId, { voice: addVoiceDto.voice }, { new: true }); + + if (!product) throw new BadRequestError(ProductMessage.NotFound); + + return { + message: ProductMessage.ProductUpdated, + product, + }; + } + //########################### + async deleteVoiceToProduct(productId: number) { + const product = await this.productRepo.model.findByIdAndUpdate(productId, { voice: null }, { new: true }); + + if (!product) throw new BadRequestError(ProductMessage.NotFound); + + return { + message: ProductMessage.ProductUpdated, + product, + }; + } + + //************************************************************** + //************************************************************** + async addVariantS(addVariantDto: AddVariantDTO, ownerId: string, productId: number) { + const session = await startSession(); + session.startTransaction(); + try { + const product = await this.validateProduct(productId, ownerId); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + this.ensureProductIsNotInDraft(product); + const category = await this.ensureCategoryTheme(product.category.toString(), addVariantDto); + + await this.checkVariantCount(product, category.theme); + + // Check if a variant with the same colorId, meterageId, or sizeId already exists + // Check if a variant with the same colorId, meterageId, or sizeId already exists + const query: FilterQuery = { product: product._id }; + + if (addVariantDto.colorId) { + query.color = addVariantDto.colorId; + } + if (addVariantDto.meterageId) { + query.meterage = addVariantDto.meterageId; + } + if (addVariantDto.sizeId) { + query.size = addVariantDto.sizeId; + } + + if (category.toString() !== CategoryThemeEnum.No_color_No_sized) { + const existingVariant = await this.productVariantRepo.model.findOne(query); + + if (existingVariant) throw new BadRequestError(ProductMessage.VariantAlreadyExists); + } + + const productVariant = await this.createProductVariant(addVariantDto, product._id, shop._id.toString(), session); + + await this.productRepo.model.findByIdAndUpdate(product._id, { $push: { variants: productVariant } }, { session }); + // product.variants.push(productVariant[0]._id); + // await product.save({ session }); + const { colorId, sizeId, meterageId } = addVariantDto; + + PriceChangeEvent.emitPriceChange({ + colorId, + sizeId, + meterageId, + product: productId, + variantId: productVariant[0]._id.toString(), + shop: shop._id.toString(), + retail_price: productVariant[0].price.retailPrice, + selling_price: productVariant[0].price.selling_price, + }); + // Commit the transaction + await session.commitTransaction(); + await session.endSession(); + return { + message: ProductMessage.VariantCreated, + productVariant: productVariant[0], + }; + } catch (error) { + // Rollback any changes made in the transaction + await session.abortTransaction(); + await session.endSession(); + throw error; // Re-throw the error to be handled by the calling errorHandler + } + } + + //************************************************************** + //************************************************************** + async updateVariantS(updateVariantDto: UpdateVariantDTO, ownerId: string, productId: number) { + //TODO:check for stock for adding special sales + const product = await this.validateProduct(productId, ownerId); + this.ensureProductIsNotInDraft(product); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + // Calculate the discount based on the discount type + const { sellingPrice, discountPercent } = this.calculateDiscount(updateVariantDto); + + const product_price: Partial = { + retailPrice: updateVariantDto.retailPrice, + selling_price: sellingPrice, + order_limit: updateVariantDto.order_limit, + is_specialSale: updateVariantDto.discount_percent || updateVariantDto.discount_value ? true : false, + discount_percent: discountPercent, + specialSale_order_limit: updateVariantDto.specialSale_order_limit, + specialSale_quantity: updateVariantDto.specialSale_quantity, + specialSale_endDate: updateVariantDto.specialSale_endDate, + }; + + const { saleFormat } = updateVariantDto; + + const updatedVariant = await this.productVariantRepo.model.findByIdAndUpdate( + updateVariantDto.variantId, + { + price: product_price, + sellerSpecialCode: updateVariantDto.sellerSpecialCode, + stock: updateVariantDto.stock, + isWholeSale: saleFormat && saleFormat.wholeSale.length > 0, + saleFormat: saleFormat ? saleFormat : { wholeSale: [] }, + }, + { + new: true, + }, + ); + + PriceChangeEvent.emitPriceChange({ + colorId: updatedVariant?.color, + sizeId: updatedVariant?.size, + meterageId: updatedVariant?.meterage, + product: productId, + variantId: updatedVariant?._id.toString() as string, + shop: shop._id.toString(), + isSpecial: product_price.is_specialSale, + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + retail_price: updatedVariant?.price.retailPrice!, + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + selling_price: updatedVariant?.price.selling_price!, + }); + + return { + message: ProductMessage.VariantUpdated, + updatedVariant, + }; + } + + //###################################################################### + //########################### product observer ######################### + async removeObserver(userId: string, productId: number, removeObserverDto: RemoveObserverDTO) { + const product = await this.validateProduct(productId); + const productVariant = await this.productVariantRepo.findById(removeObserverDto.variantId); + + if (product._id !== productVariant?.product) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + + await this.productObserveRepo.model.findOneAndDelete({ user: userId, product: product._id, variant: productVariant._id }); + + return { + message: CommonMessage.Deleted, + }; + } + + async addObserver(userId: string, productId: number, addObserverDto: AddObserverDTO) { + const product = await this.validateProduct(productId); + const productVariant = await this.productVariantRepo.findById(addObserverDto.variantId); + + if (product._id !== productVariant?.product) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + + const existObserver = await this.productObserveRepo.model.findOne({ + user: userId, + product: productId, + variant: productVariant._id, + }); + + if (!existObserver) { + const newObserver = await this.productObserveRepo.model.create({ + user: userId, + product: productId, + variant: addObserverDto.variantId, + registeredPrice: productVariant.price.selling_price, + }); + return { + message: CommonMessage.Created, + newObserver, + }; + } + + return { + message: CommonMessage.Created, + observer: existObserver, + }; + } + //###################################################################### + //########################### product wishlist ######################### + + async checkProductInWishlist(userId: string, productId: number, variantId: string) { + if (!isValidObjectId(variantId)) throw new BadRequestError([CommonMessage.NotValidId, variantId]); + await this.validateProduct(productId); + const isInWishlist = await this.wishlistRepo.model.findOne({ user: userId, product: productId, variant: variantId }); + return { + isInWishlist: !!isInWishlist, + }; + } + + async removeWishlist(userId: string, productId: number, removeDto: RemoveWishlistDTO) { + const product = await this.validateProduct(productId); + const productVariant = await this.productVariantRepo.findById(removeDto.variantId); + if (product._id !== productVariant?.product) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + + await this.wishlistRepo.model.findOneAndDelete({ user: userId, product: product._id, variant: productVariant._id }); + return { + message: CommonMessage.Deleted, + }; + } + + async addToWishlist(userId: string, productId: number, addDto: AddWishlistDTO) { + const product = await this.validateProduct(productId); + const productVariant = await this.productVariantRepo.findById(addDto.variantId); + + if (product._id !== productVariant?.product) throw new BadRequestError(CartMessage.ProductNotBelongToVariant); + + const existItem = await this.wishlistRepo.model.findOne({ user: userId, product: productId, variant: productVariant._id }); + if (!existItem) { + const newItem = await this.wishlistRepo.model.create({ + user: userId, + product: productId, + variant: addDto.variantId, + }); + return { + message: CommonMessage.Created, + wishlistItem: newItem, + }; + } + + return { + message: CommonMessage.Created, + wishlistItem: existItem, + }; + } + //###################################################################### + //###################################################################### + async updateStock(operations: Array<{ variantId: string; quantity: number }>, type: "increase" | "decrease", session: ClientSession) { + const bulkOperations = []; + + for (const operation of operations) { + const { variantId, quantity } = operation; + + const product = await this.productVariantRepo.findById(variantId); + + if (!product) { + throw new BadRequestError(`Product variant with id ${variantId} not found`); + } + + if (type === "decrease" && product.stock < quantity) { + throw new BadRequestError(`Insufficient stock for product variant with id ${variantId}`); + } + + bulkOperations.push({ + updateOne: { + filter: { _id: variantId }, + update: { + $inc: { stock: type === "increase" ? quantity : -quantity }, + }, + }, + }); + } + + if (bulkOperations.length > 0) { + await this.productVariantRepo.model.bulkWrite(bulkOperations, { session }); + } + } + + //###################################################################### + //###################################################################### + async reduceStock(productVariantId: string, quantity: number, session: ClientSession) { + const product = await this.productVariantRepo.findById(productVariantId); + + if (!product) { + throw new BadRequestError(`Product variant with id ${productVariantId} not found`); + } + + if (product.stock < quantity) { + throw new BadRequestError(`Insufficient stock for product variant with id ${productVariantId}`); + } + + product.stock -= quantity; + + await product.save({ session }); + } + //###################################################################### + //###################################################################### + + async increaseStock(productVariantId: string, quantity: number, session: ClientSession): Promise { + const product = await this.productVariantRepo.findById(productVariantId); + + if (!product) { + throw new Error(`Product variant with id ${productVariantId} not found`); + } + + product.stock += quantity; + + await product.save({ session }); + } + //############################################################### + //###################### product ads ############################ + + async addProductAdS(sellerId: string, createDto: ProductAdDTO, productId: number) { + await this.validateProduct(productId, sellerId); + const newAd = await this.productAdRepo.model.create({ + product: productId, + seller: sellerId, + variant: createDto.variantId, + ...createDto, + }); + + return { + message: CommonMessage.Created, + newAd, + }; + } + //************************************************************** + //************************************************************** + + async getProductAds(sellerId: string, productId: number, variantId: string) { + await this.validateProduct(productId, sellerId); + const productAd = await this.productAdRepo.model.findOne({ product: productId, seller: sellerId, variant: variantId }); + if (!productAd) throw new BadRequestError(AdMessage.NotFoundWithId); + const totalCost = this.getTotalCostForAd(productAd); + return { + productAd, + totalCost, + }; + } + + //############################################################### + //######################### get product ######################### + + async getAdminPanelProducts(ownerRef: OwnerRef, queryDto: SellerProductQueryDTO) { + const shops = await this.shopeRepo.model.find({ ownerRef }).select("_id"); + const shopIds = shops.map((shop) => shop._id); + const { docs, count } = await this.productRepo.getSellersProducts(shopIds, queryDto); + const brands = docs.map(({ brand }: any) => BrandDTO.transformBrand(brand)); + const categories = docs.map(({ category }: any) => CategoryTreeDTO.transformCategory(category)); + const products = docs.map((doc: IProduct) => SellerPanelProductDTO.transformProduct(doc)); + + return { + brands, + categories, + count, + products, + }; + } + + async findSellerProduct(ownerId: string, queryDto: SellerProductQueryDTO) { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const { docs, count, category } = await this.productRepo.getSellerProducts(shop._id.toString(), queryDto); + + const brands = docs.map(({ brand }: any) => BrandDTO.transformBrand(brand)); + const categories = category.map((cat: any) => CategoryTreeDTO.transformCategory(cat)); + const products = docs.map((doc: IProduct) => SellerPanelProductDTO.transformProduct(doc)); + + return { + brands, + categories, + count, + products, + }; + } + + async filterProductsByStatus(queries: FilterByStatusProductsQueries) { + const { docs, count } = await this.productRepo.getProductsByStatus(queries); + + const brands = docs.map(({ brand }: any) => BrandDTO.transformBrand(brand)); + const categories = docs.map(({ category }: any) => CategoryTreeDTO.transformCategory(category)); + const products = docs.map((doc: IProduct) => SellerPanelProductDTO.transformProduct(doc)); + + return { + brands, + categories, + count, + products, + }; + } + + async getProductDetailsForSellerPanel(ownerId: string, productId: number) { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + await this.validateProduct(productId, ownerId); + const product = await this.productRepo.model.findById(productId).populate("category brand"); + return { + product, + }; + } + + async getProductDetailsForAdminPanel(productId: number) { + await this.validateProduct(productId); + const product = await this.productRepo.model.findById(productId).populate("category brand"); + return { + product, + }; + } + //************************************************************** + //************************************************************** + async getAllVariantS(ownerId: string, productId: number, queries: VariantQueries) { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(productId, ownerId); + const { docs, count } = await this.productVariantRepo.getProductVariants(shop._id.toString(), product._id, queries); + const productVariants = docs.map((doc: IProductVariant) => ProductVariantDTO.transformProduct(doc)); + + return { count, productVariants }; + } + + //************************************************************** + //************************************************************** + async getAllProductsVariantsS(ownerId: string, queries: VariantQueries) { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const { docs, count } = await this.productVariantRepo.getAllProductsVariants(shop._id.toString(), queries); + const productsVariants = docs.map((doc: IProductVariant) => ProductVariantDTO.transformProduct(doc)); + + return { count, productsVariants }; + } + + async getSingleVariant(ownerId: string, productId: number, variantId: string) { + if (!isValidObjectId(variantId)) throw new BadRequestError(ProductMessage.ProductVariantNotFound); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const product = await this.validateProduct(productId, ownerId); + const docs = await this.productVariantRepo.getSingleVariant(shop._id.toString(), product._id, variantId); + const productVariant = ProductVariantDTO.transformProduct(docs[0]); + + return { + productVariant, + // product, + }; + } + //************************************************************** + //************************************************************** + async getProductDetails(productId: number) { + await this.validateProduct(productId); + const docs = await this.productRepo.getProductDetails(productId); + if ((!docs?.[0]?.variants || docs?.[0]?.variants.length === 0) && docs?.[0]?.category.theme !== "noColor_noSize") { + throw new BadRequestError(CommonMessage.NotEnoughVariant); + } + const product = ProductDTO.transformProduct(docs[0]); + const categoryPath = await this.categoryRepo.getCategoryPath(product.category._id.toString()); + return { product, categoryPath }; + } + //************************************************************** + + async draft(id: number) { + const updatedProduct = await this.productRepo.model.findByIdAndUpdate(id, { status: ProductStatus.Draft }, { new: true }); + if (!updatedProduct) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedProduct, + }; + } + + async approve(id: number) { + const updatedProduct = await this.productRepo.model.findByIdAndUpdate(id, { status: ProductStatus.Approved }, { new: true }); + if (!updatedProduct) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedProduct, + }; + } + + async pending(id: number) { + const updatedProduct = await this.productRepo.model.findByIdAndUpdate(id, { status: ProductStatus.Pending }, { new: true }); + if (!updatedProduct) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedProduct, + }; + } + + async reject(id: number, rejectCommentDto: RejectCommentDTO) { + const updatedProduct = await this.productRepo.model.findByIdAndUpdate( + id, + { status: ProductStatus.Rejected, adminComments: rejectCommentDto.adminComments }, + { new: true }, + ); + if (!updatedProduct) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedProduct, + }; + } + //************************************************************** + //************************************************************** + async getProductPriceHistoryS(productId: number, since: string) { + const priceHistory = await this.priceHistoryRepo.getPriceHistory(productId, since); + return { + priceHistory, + }; + } + //************************************************************** + //************************************************************** + async searchForCompareProductsS(queries: CompareSearchQueries) { + const product = await this.validateProduct(+queries.productId); + const { docs, count } = await this.productRepo.getProductsForCompareSearch(product.category.toString(), queries); + const products = docs.map((doc: IProduct) => ProductDTO.transformProduct(doc)); + return { + count, + products, + }; + } + //************************************************************** + //************************************************************** + async compareProducts(productIds: string[] | string) { + const docs = await this.productRepo.getProductSpecification(productIds); + const products = docs.map((doc: IProduct) => ProductDTO.transformProduct(doc)); + return { + products, + }; + } + + //############################################################### + //################### delete a product ######################### + async deleteProduct(ownerId: string, productId: number) { + const session = await startSession(); + session.startTransaction(); + try { + // validate product existence and ownership + const product = await this.validateProduct(productId, ownerId); + const pStatus = product.status; + + if (pStatus !== ProductStatus.Draft) throw new BadRequestError(ProductMessage.CanNotDelete); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + // const productVariants = await this.productVariantRepo.model.find({ product: productId, shop: shop._id.toString() }, { _id: 1 }); + + await this.productRepo.model.findByIdAndDelete(product._id, { session }); + + // delete all variants belong to the product + // if (productVariants.length > 0) { + // await this.productVariantRepo.model.deleteMany({ _id: { $in: productVariants.map((variant) => variant._id) } }, { session }); + // } + + await session.commitTransaction(); + await session.endSession(); + return { + message: ProductMessage.Deleted, + }; + } catch (error) { + // Rollback any changes made in the transaction + await session.abortTransaction(); + await session.endSession(); + throw error; // Re-throw the error to be handled by the calling errorHandler + } + } + + async softDelete(ownerId: string, productId: number) { + const session = await startSession(); + session.startTransaction(); + try { + // validate product existence and ownership + const product = await this.validateProduct(productId, ownerId); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + // const productVariants = await this.productVariantRepo.model.find({ product: productId, shop: shop._id.toString() }, { _id: 1 }); + + await this.productRepo.model.findByIdAndUpdate(product._id, { deleted: true }, { session }); + + // // delete all variants belong to the product + // if (productVariants.length > 0) { + // await this.productVariantRepo.model.deleteMany({ _id: { $in: productVariants.map((variant) => variant._id) } }, { session }); + // } + await session.commitTransaction(); + await session.endSession(); + return { + message: ProductMessage.Deleted, + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //################################################################ + async cloneProduct(ownerId: string, productId: number) { + const product = await this.validateProduct(productId, ownerId); + + const cloneProduct = await this.productRepo.model.create({ + title_fa: `${product.title_fa}-c`, + title_en: `${product.title_en}-c`, + model: `${product.model}-c`, + source: product.source, + description: product.description, + metaDescription: product.metaDescription, + tags: product.tags, + advantages: product.advantages, + disAdvantages: product.disAdvantages, + specifications: product.specifications, + category: product.category, + shop: product.shop, + brand: product.brand, + imagesUrl: product.imagesUrl, + status: ProductStatus.Draft, + step: CreateProductStep.Image, + isFake: true, + }); + + return { + message: ProductMessage.Cloned, + cloneProduct, + }; + } + + //############################################################### + //################### product comment and question ############## + async addQuestionS(userId: string, productId: number, createQuestionDto: CreateQuestionDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const product = await this.validateProduct(productId); + + const newQuestion = await this.questionRepo.model.create( + [ + { + content: createQuestionDto.content, + user: userId, + product: productId, + }, + ], + { session }, + ); + + await this.notificationService.notifyProductQuestion(product.title_fa, session); + + await session.commitTransaction(); + return { + message: "سوال با موفقیت اضافه شد", + content: newQuestion[0].content, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + async getCommentsForReport() { + const comments = await this.commentRepo.getCommentsForReport(); + return comments; + } + + async getQuestionsForReport() { + const questions = await this.questionRepo.getQuestionsForReport(); + return questions; + } + + //############################################################### + + async getQuestions(productId: number) { + await this.validateProduct(productId); + + const docs = await this.questionRepo.model.find({ product: productId, status: Question_CommentStatus.Accepted }); + const questions = docs.map((doc) => QuestionDTO.transformQuestion(doc)); + return { + questions, + }; + } + //############################################################### + + async getQuestionsForAdminPanel(query: ProductsQuestionsQueries) { + const { count, docs } = await this.questionRepo.getProductsQuestions(query); + const questions = docs.map((doc: IQuestion) => QuestionDTO.transformQuestion(doc)); + return { + questions, + count, + }; + } + //############################################################### + + async answerQuestion(id: string, createDto: CreateAnswerQuestionDTO, ownerId: string, ownerRef: OwnerRef) { + const question = await this.questionRepo.model.findById(id); + if (!question) throw new BadRequestError(CommonMessage.NotValidId); + + const shop = await this.shopeRepo.model.findOne({ owner: ownerId, ownerRef }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + question.answers?.push({ + content: createDto.content, + owner: shop.owner, + ownerRef: shop.ownerRef, + }); + + await question.save(); + + return { + message: CommonMessage.Updated, + question, + }; + } + //############################################################### + + async approveQuestion(id: string) { + const updatedQuestion = await this.questionRepo.model.findByIdAndUpdate(id, { status: Question_CommentStatus.Accepted }, { new: true }); + if (!updatedQuestion) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedQuestion, + }; + } + + //############################################################### + + async addCommentS(userId: string, productId: number, createCommentDto: CreateCommentDTO) { + const session = await startSession(); + session.startTransaction(); + + try { + const product = await this.validateProduct(productId); + + const newComment = await this.commentRepo.model.create( + [ + { + title: createCommentDto.title, + advantage: createCommentDto.advantage, + disAdvantage: createCommentDto.disAdvantage, + content: createCommentDto.content, + user: userId, + product: productId, + rate: createCommentDto.rate, + }, + ], + { session }, + ); + + await this.notificationService.notifyProductComment(product.title_fa, session); + + await session.commitTransaction(); + + return { + message: "کامنت با موفقیت اضافه شد", + content: newComment[0].content, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + async getComments(productId: number) { + await this.validateProduct(productId); + const docs = await this.commentRepo.model.find({ product: productId, status: Question_CommentStatus.Accepted }); + const comments = docs.map((doc) => CommentDTO.transformComment(doc)); + return { + comments, + }; + } + + async getCommentsForAdminPanel(query: ProductsCommentsQueries) { + const { count, docs } = await this.commentRepo.getProductsComments(query); + const comments = docs.map((doc: IComment) => CommentDTO.transformComment(doc)); + return { + comments, + count, + }; + } + + async approveComment(id: string) { + const updatedComment = await this.commentRepo.model.findByIdAndUpdate(id, { status: Question_CommentStatus.Accepted }, { new: true }); + if (!updatedComment) throw new BadRequestError(CommonMessage.NotValidId); + + const product = await this.productRepo.model.findById(updatedComment.product); + if (!product) { + throw new BadRequestError(ProductMessage.ProductNotFound); + } + const rate = product.rate || 0; + const totalRate = product.totalRate || 0; + const commentRate = updatedComment.rate || 0; + + if (isNaN(rate) || isNaN(totalRate) || isNaN(commentRate)) { + throw new BadRequestError("Invalid rate values"); + } + + product.rate = (rate * totalRate + commentRate) / (totalRate + 1); + product.totalRate += 1; + await product.save(); + + return { + message: CommonMessage.Updated, + updatedComment, + }; + } + + //############################################################### + //################### toggle the variant status ################# + async activateVariantS(ownerId: string, activateDto: ActivateVariantDTO, productId: number) { + await this.validateProduct(productId, ownerId); + const productVariant = await this.productVariantRepo.model.findById(activateDto.variantId); + + if (!productVariant) throw new BadRequestError(ProductMessage.ProductVariantNotFound); + + const market_status = activateDto.isActive /*&& productVariant.stock > 0 */ + ? ProductMarketStatus.Marketable + : ProductMarketStatus.stop_production; + + await this.productVariantRepo.model.findByIdAndUpdate(activateDto.variantId, { market_status }); + + return { + message: CommonMessage.Updated, + }; + } + + //############################################################### + + async setFreeShipping(ownerId: string, productId: number, variantId: string) { + await this.validateProduct(productId, ownerId); + const productVariant = await this.productVariantRepo.model.findById(variantId); + + if (!productVariant) throw new BadRequestError(ProductMessage.ProductVariantNotFound); + + productVariant.isFreeShip = !productVariant.isFreeShip; + + await productVariant.save(); + + return { + message: CommonMessage.Updated, + }; + } + + //############################################################### + //################### product report ############################# + + async getReportQuestions() { + const questions = await this.reportQuestionRepo.findAll(); + return { + questions, + }; + } + + async createReportQuestion(createDto: CreateReportQuestionDTO) { + const createdQuestion = await this.reportQuestionRepo.model.create({ title: createDto.title, type: ReportQuestionType.CHECKBOX }); + return { + message: CommonMessage.Created, + reportQuestion: createdQuestion, + }; + } + + //************************************************************** + //************************************************************** + async addReportS(userId: string, createReportDto: CreateReportDTO, productId: number) { + await this.validateProduct(productId); + + const questionsId = createReportDto.answers.map((answer) => answer.questionId); + + const newReport = await this.productReportRepo.model.create({ + user: userId, + product: productId, + reason: questionsId, + description: createReportDto.description, + }); + + return { + message: CommonMessage.Created, + newReport, + }; + } + + async getProductReportForAdmin() { + const reports = await this.productReportRepo.model.find().populate([{ path: "user" }, { path: "product" }, { path: "reason" }]); + + return { reports }; + } + + //############################################################### + //########################helper methods######################### + + //************************************************************** + //************************************************************** + //****method to check if product belong to current sessions user\ + private async validateProduct(productId: number, ownerId?: string) { + //if product id is falsy like NaN + if (!productId) throw new BadRequestError(ProductMessage.ProductNotFound); + + //check if product exist + const product = await this.productRepo.findById(productId); + + if (!product) throw new BadRequestError(ProductMessage.ProductNotFound); + + //check if product belong to seller + + if (ownerId) { + const shop = await this.shopeRepo.model.findOne({ owner: ownerId }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + if (product.shop._id.toString() !== shop._id.toString()) throw new ForbiddenError(ProductMessage.CanNotUpdate); + } + + return product; + } + //************************************************************** + //************************************************************** + //****method to check if attribute is correct based on theme + private async ensureCategoryTheme(categoryId: string, addVariantDto: AddVariantDTO) { + const category = await this.categoryRepo.findById(categoryId); + + if (!category) { + throw new BadRequestError(CategoryMessage.NotValidId); + } + + // Check if the category theme is color, size, or meterage + if (category.theme === CategoryThemeEnum.Colored) { + if (addVariantDto.sizeId || addVariantDto.meterageId) { + throw new BadRequestError(AttributeMessage.SizeNotAllowedForColoredTheme); + } + if (!addVariantDto.colorId) { + throw new BadRequestError(AttributeMessage.ColorRequiredForColoredTheme); + } + } else if (category.theme === CategoryThemeEnum.Sized) { + if (addVariantDto.colorId || addVariantDto.meterageId) { + throw new BadRequestError(AttributeMessage.ColorNotAllowedForSizedTheme); + } + if (!addVariantDto.sizeId) { + throw new BadRequestError(AttributeMessage.SizeRequiredForSizedTheme); + } + } else if (category.theme === CategoryThemeEnum.Meterage) { + if (addVariantDto.colorId || addVariantDto.sizeId) { + throw new BadRequestError(AttributeMessage.ColorOrSizeNotAllowedForMeterageTheme); + } + if (!addVariantDto.meterageId) { + throw new BadRequestError(AttributeMessage.MeterageRequiredForMeterageTheme); + } + } else { + // For other themes that are neither color, size, nor meterage + if (addVariantDto.sizeId || addVariantDto.colorId || addVariantDto.meterageId) { + throw new BadRequestError(AttributeMessage.ThemeIsNoColorNoSizeNoMeterage); + } + } + + return category; + } + //************************************************************** + //************************************************************** + //****method to check for duplicate product + private async checkForDuplicateProduct(model: string) { + const exist = await this.productRepo.model.exists({ model }); + if (exist) throw new BadRequestError(ProductMessage.DuplicateName); + return true; + } + //************************************************************** + //************************************************************** + //****method to check the product is not in draft status + private ensureProductIsNotInDraft(product: IProduct) { + if (product.status === ProductStatus.Draft) { + throw new BadRequestError(ProductMessage.ProductIsInDraftStep); + } + return true; + } + + //************************************************************** + //************************************************************** + private ensureDraftStatus(status: ProductStatus) { + if (status !== ProductStatus.Draft) { + throw new BadRequestError(ProductMessage.ProductMustBeInDraft); + } + } + //************************************************************** + //************************************************************** + //****method to check the product is in correct step + private ensureCorrectStep(currentStep: CreateProductStep, expectedStep: CreateProductStep) { + // const currentStepIndex = this.stepOrder.indexOf(currentStep); + // const expectedStepIndex = this.stepOrder.indexOf(expectedStep); + + if (currentStep >= expectedStep) { + throw new BadRequestError(ProductMessage.ProductIsInNextStep); + } + } + //************************************************************** + //************************************************************** + //****method to map the product for send it in response + private mapToDraftProductDTO(product: IProduct, nextStep: string) { + return { + id: product._id, + title_fa: product.title_fa, + title_en: product.title_en, + model: product.model, + nextStep, + }; + } + //************************************************************** + //************************************************************** + //****method to create variant on product + private async createProductVariant(addVariantDto: AddVariantDTO, productId: number, shopId: string, session: ClientSession) { + const dimensions: IDimensions = { + package_weight: addVariantDto.package_weight, + package_height: addVariantDto.package_height, + package_length: addVariantDto.package_length, + package_width: addVariantDto.package_width, + }; + + const product_price: Partial = { + order_limit: addVariantDto.order_limit, + retailPrice: addVariantDto.retail_price, + }; + + return await this.productVariantRepo.model.create( + [ + { + product: productId, + shop: shopId, + warranty: addVariantDto.warrantyId, + // shipmentMethod: addVariantDto.shipmentsId, + color: addVariantDto.colorId, + size: addVariantDto.sizeId, + meterage: addVariantDto.meterageId, + dimensions, + market_status: ProductMarketStatus.Marketable, + price: product_price, + stock: addVariantDto.stock, + postingTime: addVariantDto.postingTime, + sellerSpecialCode: addVariantDto.sellerSpecialCode, + }, + ], + { session }, + ); + } + //************************************************************** + //************************************************************** + //****method to calculate Discount + private calculateDiscount(updateVariantDto: UpdateVariantDTO): { sellingPrice: number; discountPercent: number } { + const { retailPrice, discount_type, discount_value, discount_percent } = updateVariantDto; + let sellingPrice = retailPrice; + let discountPercent = 0; + + if (discount_type === ProductDiscountType.Fixed && discount_value) { + sellingPrice = retailPrice - discount_value; + discountPercent = (discount_value / retailPrice) * 100; + } else if (discount_type === ProductDiscountType.Percent && discount_percent) { + discountPercent = discount_percent; + sellingPrice = retailPrice - (retailPrice * discount_percent) / 100; + } + + return { sellingPrice, discountPercent }; + } + //************************************************************** + //************************************************************** + //****method to calculate total cost of the ad till now + private getTotalCostForAd(ad: IProductAd): number { + let totalCost = 0; + + if (ad.type === AdType.Click) { + totalCost = (ad.clickCount || 0) * ad.cost; + } else if (ad.type === AdType.Daily) { + const currentDate = TimeService.getCurrentPersianDate(); + + // convert ad.startDate and ad.endDate from Persian (Jalali) to Gregorian + const startDateGregorian = TimeService.convertPersianToGregorian(ad.startDate); + const endDateGregorian = TimeService.convertPersianToGregorian(ad.endDate); + + // Calculate the number of days the ad has been active, using Gregorian dates + const startDate = new Date(startDateGregorian); + const endDate = new Date(endDateGregorian); + const currentDateObj = new Date(TimeService.convertPersianToGregorian(currentDate)); + + // Calculate the number of active days + const daysActive = Math.ceil((Math.min(currentDateObj.getTime(), endDate.getTime()) - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + // Total cost is the number of active days multiplied by the daily cost + totalCost = daysActive * ad.cost; + } + + return totalCost; + } + + private async checkVariantCount(product: IProduct, categoryTheme: CategoryThemeEnum) { + if (categoryTheme !== CategoryThemeEnum.No_color_No_sized) return true; + + const variantCount = product.variants.length; + if (variantCount && variantCount === 0) return true; + + throw new BadRequestError(ProductMessage.ProductWithNoVariantCategoryTheme); + } + + //############################################################### + //###################### popular product ############################ + + async addPopularProduct(productId: number) { + await this.validateProduct(productId); + const newPopular = await this.popularProductRepo.model.create({ + product: productId, + }); + + return { + message: CommonMessage.Created, + newPopular, + }; + } + + async addIncredibleOffers(paramDto: AddIncredibleOffersParamDto) { + const deleteIncredible = await this.incredibleOffersRepo.model.findOneAndDelete({ product: paramDto.id, variant: paramDto.variantId }); + if (deleteIncredible) { + return { + message: CommonMessage.Deleted, + deleteIncredible, + }; + } + + const incredibleOffer = await this.incredibleOffersRepo.model.create({ product: paramDto.id, variant: paramDto.variantId }); + + return { + message: CommonMessage.Created, + incredibleOffer, + }; + } + + async deletePopularProduct(productId: number) { + await this.validateProduct(productId); + const deletedPopular = await this.popularProductRepo.model.findOneAndDelete({ + product: productId, + }); + console.log(deletedPopular); + return { + message: CommonMessage.Deleted, + }; + } + + async getPopularProducts(userId?: string) { + const popularProducts = await this.popularProductRepo.getPopularProducts(userId); + return { + popularProducts, + }; + } + + async getIncredibleOffers(paginationDto: PaginationDTO) { + const { skip, limit } = paginationUtils(paginationDto); + const { count, incredibleOffers } = await this.incredibleOffersRepo.getIncredibleOffersForAdmin(skip, limit); + + return { + incredibleOffers, + count, + }; + } + + async getSpecialProductsForAdmin(paginationDto: PaginationDTO) { + const { skip, limit } = paginationUtils(paginationDto); + + const { specialSaleProducts, count } = await this.productRepo.getIncredibleOffers(skip, limit); + return { + specialSaleProducts, + count, + }; + } + + async getProductCount() { + return await this.productRepo.getProductCount(); + } + + async getPendingProductCount() { + return await this.productRepo.getPendingProductCount(); + } + + async updateProduct(productId: number, updateDto: UpdateProductDTO) { + const product = await this.validateProduct(productId); + const updatedProduct = await this.productRepo.model.findByIdAndUpdate({ _id: product._id }, { ...updateDto }, { new: true }); + console.log({ updatedProduct }); + return { updatedProduct }; + } + + async deleteProductQuestion(questionId: string) { + const question = await this.questionRepo.model.findByIdAndDelete(questionId); + if (!question) { + throw new BadRequestError(ProductMessage.CanNotDeleteQuestion); + } + return { + message: CommonMessage.Deleted, + }; + } + + async deleteProductComment(commentId: string) { + const comment = await this.commentRepo.model.findByIdAndDelete(commentId); + if (!comment) { + throw new BadRequestError(ProductMessage.CanNotDeleteComment); + } + return { + message: CommonMessage.Deleted, + }; + } + + //************************************************************** + //************************************************************** + //****method to calculate search cost + // private calculateCostForSearch(ad: IProductAd): number { + // let searchCost = 0; + + // if (ad.type === AdType.Click) { + // if (ad.clickCount && ad.maxClicks && ad.clickCount >= ad.maxClicks) { + // searchCost = 0; + // console.log("Max clicks reached, no more cost will be incurred for this ad."); + // } else { + // searchCost = ad.cost; + // ad.clickCount = (ad.clickCount || 0) + 1; + // } + // } else if (ad.type === AdType.Daily) { + // searchCost = ad.cost; + // } + + // return searchCost; + // } +} + +export { ProductService }; diff --git a/src/modules/return/DTO/return.dto.ts b/src/modules/return/DTO/return.dto.ts new file mode 100644 index 0000000..ad7274f --- /dev/null +++ b/src/modules/return/DTO/return.dto.ts @@ -0,0 +1,124 @@ +import { Expose, Transform, Type, plainToClass } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, Min, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { StatusEnum } from "../../../common/enums/status.enum"; +import { OrderModel } from "../../order/models/order.model"; +import { OrderItemModel } from "../../order/models/orderItem.model"; +import { ReturnReasonModel } from "../models/returnReason.model"; +// import { IReturnOrder } from "../models/Abstraction/IReturn"; + +export class ReturnRequestItemsDTO { + @Expose() + @IsNotEmpty() + @IsValidId(OrderItemModel) + @IsString() + @ApiProperty({ type: "string", description: "the id of orderItem ", example: "66f7bf9a71d13496054d7c2a" }) + orderItemId: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the id of shipment item ", example: "66f7bf9a71d13496054d7c2a" }) + shipmentItemId: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @Min(1) + @ApiProperty({ type: "number", description: "the number of return quantity ", example: 1 }) + quantity: number; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "user comment on the return request", example: "رنگ مغایرت دارد" }) + comment: string; + + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ReturnReasonModel) + @ApiProperty({ type: "string", description: "the id of returnReason ", example: "66f7bf9a71d13496054d7c2a" }) + reason: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the image of return if mandatory ", example: "https://cdn.com" }) + imagesUrl?: string; +} +export class ReturnOrderDTO { + @Expose() + _id: string; + + //TODO:fix this + @Expose() + order: Record; + + @Expose() + status: StatusEnum; + + @Expose() + total_price: number; + + @Expose() + @Transform(({ value }) => new Date(value).toISOString()) + createdAt: string; + + @Expose() + @Transform(({ value }) => new Date(value).toISOString()) + updatedAt: string; + + @Expose() + @Type(() => ReturnRequestItemsDTO) + returnOrderItems: ReturnRequestItemsDTO[]; + + public static transformReturnOrder(data: any): ReturnOrderDTO { + const returnOrderDTO = plainToClass(ReturnOrderDTO, data, { + enableImplicitConversion: true, + }); + return returnOrderDTO; + } +} + +export class ReturnRequestDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(OrderModel) + @ApiProperty({ type: "number", description: "the id of order ", example: 401056 }) + orderId: string; + + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ReturnRequestItemsDTO) + @ApiProperty({ + type: "Array", + description: "the order item to return", + example: [ + { + orderItemId: "66f7bf9a71d13496054d7c2a", + shipmentItemId: "66f7bf9a71d13496054d7c2a", + quantity: 2, + comment: "رنگ مغایرت دارد", + reason: "66f7bf9a71d13496054d7c2a", + imagesUrl: "https://cdn.com", + }, + { + orderItemId: "66f7bf9a71d13496054d7c2a", + shipmentItemId: "66f7bf9a71d13496054d7c2a", + quantity: 1, + comment: "جنس مغایرت دارد", + reason: "66f7bf9a71d13496054d7c2a", + imagesUrl: "https://cdn.com", + }, + ], + }) + items: ReturnRequestItemsDTO[]; +} diff --git a/src/modules/return/DTO/returnReason.dto.ts b/src/modules/return/DTO/returnReason.dto.ts new file mode 100644 index 0000000..65c04ba --- /dev/null +++ b/src/modules/return/DTO/returnReason.dto.ts @@ -0,0 +1,43 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { FineRuleModel } from "../../fine/models/fineRule.model"; + +export class ReasonDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of reason", example: "سایز، ابعاد یا اندازه نامناسب کالا" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify the image is mandatory for this reason", example: true }) + is_mandatory_picture: boolean; + + @Expose() + @IsNotEmpty() + @IsValidId(FineRuleModel) + @IsString() + @ApiProperty({ type: "string", description: "the id of Fine Rule ", example: "66f7bf9a71d13496054d7c2a" }) + fineRule: string; +} + +export class UpdateReasonDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title of reason", example: "سایز، ابعاد یا اندازه نامناسب کالا" }) + title?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify the image is mandatory for this reason", example: true }) + is_mandatory_picture?: boolean; +} diff --git a/src/modules/return/models/Abstraction/IReturn.ts b/src/modules/return/models/Abstraction/IReturn.ts new file mode 100644 index 0000000..6989970 --- /dev/null +++ b/src/modules/return/models/Abstraction/IReturn.ts @@ -0,0 +1,10 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IReturnOrder { + order: number; + user: Types.ObjectId; + status: StatusEnum; + total_price: number; +} diff --git a/src/modules/return/models/Abstraction/IReturnItem.ts b/src/modules/return/models/Abstraction/IReturnItem.ts new file mode 100644 index 0000000..1e5c26c --- /dev/null +++ b/src/modules/return/models/Abstraction/IReturnItem.ts @@ -0,0 +1,14 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IReturnOrderItem { + returnOrderId: Types.ObjectId; + orderItem: Types.ObjectId; + shipmentItem: Types.ObjectId; + quantity: number; + comment: string; + reason: Types.ObjectId; + imagesUrl: string; + status: StatusEnum; +} diff --git a/src/modules/return/models/Abstraction/IReturnReason.ts b/src/modules/return/models/Abstraction/IReturnReason.ts new file mode 100644 index 0000000..ac4c6a6 --- /dev/null +++ b/src/modules/return/models/Abstraction/IReturnReason.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface IReturnReason { + title: string; + is_mandatory_picture: boolean; + fineRule: Types.ObjectId; +} diff --git a/src/modules/return/models/return.model.ts b/src/modules/return/models/return.model.ts new file mode 100644 index 0000000..d7213a4 --- /dev/null +++ b/src/modules/return/models/return.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { IReturnOrder } from "./Abstraction/IReturn"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const ReturnOrderSchema = new Schema( + { + order: { type: Number, ref: "Order", required: true }, + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + total_price: { type: Number, default: 0 }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ReturnOrderModel = model("ReturnOrder", ReturnOrderSchema); +export { ReturnOrderModel }; diff --git a/src/modules/return/models/returnItem.model.ts b/src/modules/return/models/returnItem.model.ts new file mode 100644 index 0000000..331f73f --- /dev/null +++ b/src/modules/return/models/returnItem.model.ts @@ -0,0 +1,21 @@ +import { Schema, model } from "mongoose"; + +import { IReturnOrderItem } from "./Abstraction/IReturnItem"; +import { StatusEnum } from "../../../common/enums/status.enum"; +//TODO:add a refund model to refund user after complete return +const ReturnOrderItemSchema = new Schema( + { + returnOrderId: { type: Schema.Types.ObjectId, ref: "ReturnOrder", required: true }, + orderItem: { type: Schema.Types.ObjectId, ref: "OrderItem", required: true }, + shipmentItem: { type: Schema.Types.ObjectId, required: true }, + quantity: { type: Number, required: true }, + comment: { type: String, required: true }, + imagesUrl: { type: String }, + reason: { type: Schema.Types.ObjectId, ref: "ReturnReason", required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ReturnOrderItemModel = model("ReturnOrderItem", ReturnOrderItemSchema); +export { ReturnOrderItemModel }; diff --git a/src/modules/return/models/returnReason.model.ts b/src/modules/return/models/returnReason.model.ts new file mode 100644 index 0000000..75dec0f --- /dev/null +++ b/src/modules/return/models/returnReason.model.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { IReturnReason } from "./Abstraction/IReturnReason"; + +const ReturnReasonSchema = new Schema( + { + title: { type: String, required: true, unique: true }, + is_mandatory_picture: { type: Boolean, default: false }, + fineRule: { type: Schema.Types.ObjectId, ref: "FineRule", required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const ReturnReasonModel = model("ReturnReason", ReturnReasonSchema); + +export { ReturnReasonModel }; diff --git a/src/modules/return/return.controller.ts b/src/modules/return/return.controller.ts new file mode 100644 index 0000000..d1e302c --- /dev/null +++ b/src/modules/return/return.controller.ts @@ -0,0 +1,62 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody } from "inversify-express-utils"; + +import { ReturnService } from "./return.service"; +import { HttpStatus } from "../../common"; +import { ReturnRequestDTO } from "./DTO/return.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/returns") +@ApiTags("Return") +class ReturnController extends BaseController { + @inject(IOCTYPES.ReturnService) returnService: ReturnService; + + //return an item of order + + @ApiOperation("get list of items that user returned ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/user", Guard.authUser()) + public async getReturnOrders(@request() req: Request) { + const user = req.user as IUser; + const data = await this.returnService.getReturnOrders(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get list of items that user ordered ==> login as user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/user/all", Guard.authUser()) + public async getAllReturnOrders(@request() req: Request) { + const user = req.user as IUser; + const data = await this.returnService.getAllUserReturnOrders(user._id.toString()); + return this.response(data); + } + + @ApiOperation("return an item of order ==> login as user") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(ReturnRequestDTO) + @ApiAuth() + @httpPost("/user", Guard.authUser(), ValidationMiddleware.validateInput(ReturnRequestDTO)) + public async returnRequest(@request() req: Request, @requestBody() returnDto: ReturnRequestDTO) { + const user = req.user as IUser; + const data = await this.returnService.returnRequest(user._id.toString(), returnDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get all return reason") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/reasons") + public async getReturnReason() { + const data = await this.returnService.getReturnReasons(); + return this.response(data); + } +} + +export { ReturnController }; diff --git a/src/modules/return/return.repository.ts b/src/modules/return/return.repository.ts new file mode 100644 index 0000000..a61cfeb --- /dev/null +++ b/src/modules/return/return.repository.ts @@ -0,0 +1,232 @@ +import { Types } from "mongoose"; + +import { IReturnOrder } from "./models/Abstraction/IReturn"; +import { IReturnOrderItem } from "./models/Abstraction/IReturnItem"; +import { IReturnReason } from "./models/Abstraction/IReturnReason"; +import { ReturnOrderModel } from "./models/return.model"; +import { ReturnOrderItemModel } from "./models/returnItem.model"; +import { ReturnReasonModel } from "./models/returnReason.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class ReturnOrderRepo extends BaseRepository { + constructor() { + super(ReturnOrderModel); + } + + async getAllReturnsForAdmin(skip: number, limit: number) { + const docs = await this.model.aggregate([ + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "returnorderitems", + localField: "_id", + foreignField: "returnOrderId", + as: "returnOrderItems", + }, + }, + { + $unwind: "$returnOrderItems", + }, + { + $lookup: { + from: "orderitems", + localField: "returnOrderItems.orderItem", + foreignField: "_id", + as: "returnOrderItems.orderItem", + }, + }, + { + $unwind: "$returnOrderItems.orderItem", + }, + { + $lookup: { + from: "products", + localField: "returnOrderItems.orderItem.shipmentItems.product", + foreignField: "_id", + as: "product", + }, + }, + { + $unwind: "$product", + }, + { + $addFields: { + product: "$product", + }, + }, + { + $lookup: { + from: "shops", + localField: "returnOrderItems.orderItem.shop", + foreignField: "_id", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $addFields: { + shopName: "$shop.shopName", + }, + }, + { + $lookup: { + from: "sellers", + localField: "shop.owner", + foreignField: "_id", + as: "seller", + }, + }, + { + $unwind: "$seller", + }, + { + $addFields: { + seller: "$seller.fullName", + }, + }, + { + $project: { + _id: 1, + order: 1, + status: 1, + total_price: 1, + shopName: 1, + seller: 1, + product: { + _id: 1, + title_fa: 1, + imagesUrl: 1, + }, + createdAt: 1, + updatedAt: 1, + returnOrderItems: { + _id: 1, + quantity: 1, + comment: 1, + reason: 1, + imagesUrl: 1, + orderItem: { + _id: 1, + totalSellingPrice: 1, + }, + }, + }, + }, + { + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + totalCount: [{ $count: "count" }], + }, + }, + { + $addFields: { + totalCount: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + + return { + count: (docs?.[0]?.totalCount as number) || 0, + docs: docs?.[0]?.data || [], + }; + } + + async getAllReturnsForUser(userId: string) { + const docs = await this.model.aggregate([ + { + $match: { + user: new Types.ObjectId(userId), + }, + }, + { + $lookup: { + from: "orders", + localField: "order", + foreignField: "_id", + as: "order", + }, + }, + { + $unwind: "$order", + }, + { + $lookup: { + from: "returnorderitems", + localField: "_id", + foreignField: "returnOrderId", + as: "returnOrderItems", + }, + }, + { + $unwind: "$returnOrderItems", + }, + { + $lookup: { + from: "orderitems", + localField: "returnOrderItems.orderItem", + foreignField: "_id", + as: "returnOrderItems.orderItem", + }, + }, + { + $unwind: "$returnOrderItems.orderItem", + }, + { + $lookup: { + from: "products", + localField: "returnOrderItems.orderItem.shipmentItems.product", + foreignField: "_id", + as: "returnOrderItems.orderItem.shipmentItems.product", + }, + }, + { + $sort: { + createdAt: -1, + }, + }, + ]); + console.log(docs); + return docs; + } +} + +export class ReturnOrderItemRepo extends BaseRepository { + constructor() { + super(ReturnOrderItemModel); + } +} + +export class ReturnReasonRepo extends BaseRepository { + constructor() { + super(ReturnReasonModel); + } +} + +export function createReturnOrderRepo(): ReturnOrderRepo { + return new ReturnOrderRepo(); +} + +export function createReturnOrderItemRepo(): ReturnOrderItemRepo { + return new ReturnOrderItemRepo(); +} + +export function createReturnReasonRepo(): ReturnReasonRepo { + return new ReturnReasonRepo(); +} diff --git a/src/modules/return/return.service.ts b/src/modules/return/return.service.ts new file mode 100644 index 0000000..dc62264 --- /dev/null +++ b/src/modules/return/return.service.ts @@ -0,0 +1,258 @@ +import { inject, injectable } from "inversify"; +import { isValidObjectId, startSession } from "mongoose"; + +import { ReturnOrderDTO, ReturnRequestDTO } from "./DTO/return.dto"; +import { ReasonDTO, UpdateReasonDTO } from "./DTO/returnReason.dto"; +import { ReturnOrderItemRepo, ReturnOrderRepo, ReturnReasonRepo } from "./return.repository"; +import { PaginationDTO } from "../../common/dto/pagination.dto"; +import { CommonMessage, OrderMessage, ShopMessage, UserMessage } from "../../common/enums/message.enum"; +import { OrderItemsStatus } from "../../common/enums/order.enum"; +import { StatusEnum } from "../../common/enums/status.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { paginationUtils } from "../../utils/pagination.utils"; +import { SMS } from "../../utils/sms.service"; +import { FineRuleRepo } from "../fine/repository/fineRule.repository"; +import { NotificationService } from "../notification/notification.service"; +import { OrderItemRepo } from "../order/order.repository"; +import { ShopRepo } from "../shop/shop.repository"; +import { UserRepository } from "../user/user.repository"; + +@injectable() +class ReturnService { + @inject(IOCTYPES.OrderItemRepo) private orderItemRepo: OrderItemRepo; + @inject(IOCTYPES.ReturnOrderRepo) private returnOrderRepo: ReturnOrderRepo; + @inject(IOCTYPES.ReturnOrderItemRepo) private returnOrderItemRepo: ReturnOrderItemRepo; + @inject(IOCTYPES.ReturnReasonRepo) private returnReasonRep: ReturnReasonRepo; + @inject(IOCTYPES.FineRuleRepo) private fineRuleRepo: FineRuleRepo; + @inject(IOCTYPES.NotificationService) private notificationService: NotificationService; + @inject(IOCTYPES.ShopRepo) private shopRepo: ShopRepo; + @inject(IOCTYPES.UserRepository) private userRepository: UserRepository; + + //####################################################### + + async getReturnOrders(userId: string) { + const returnOrders = await this.returnOrderRepo.model.find({ user: userId }); + return { + returnOrders, + }; + } + + //####################################################### + async getAllReturnOrders(queryDto: PaginationDTO) { + const { skip, limit } = paginationUtils(queryDto); + const { docs, count } = await this.returnOrderRepo.getAllReturnsForAdmin(skip, limit); + const returnsOrders = docs.map((doc: any) => ReturnOrderDTO.transformReturnOrder(doc)); + return { + returnsOrders, + count, + }; + } + + //####################################################### + async getAllUserReturnOrders(userId: string) { + const returnOrders = await this.returnOrderRepo.getAllReturnsForUser(userId); + return { + returnOrders, + }; + } + + //####################################################### + async returnRequest(userId: string, returnDto: ReturnRequestDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const user = await this.userRepository.findById(userId); + if (!user) throw new BadRequestError(UserMessage.UserNotFound); + // create ReturnOrder + let total_return_price = 0; + const returnOrder = await this.returnOrderRepo.model.create([{ user: userId, order: returnDto.orderId }], { session }); + + const returnOrderDoc = returnOrder[0]; + + // Create ReturnOrderItems + for (const item of returnDto.items) { + const orderItem = await this.orderItemRepo.model.findById(item.orderItemId).session(session); + if (!orderItem) { + throw new BadRequestError(`OrderItem with ID ${item.orderItemId} not found.`); + } + + const shipmentItem = orderItem.shipmentItems.find((shipItem) => shipItem._id?.equals(item.shipmentItemId)); + if (!shipmentItem) { + throw new BadRequestError(`ShipmentItem with ID ${item.shipmentItemId} not found in OrderItem ${item.orderItemId}.`); + } + + if (item.quantity > shipmentItem.quantity - shipmentItem.cancelled_quantity! - shipmentItem.returned_quantity!) { + throw new BadRequestError(`Return quantity for ShipmentItem ${item.shipmentItemId} exceeds the available quantity.`); + } + + total_return_price += item.quantity * shipmentItem.selling_price; + + await this.returnOrderItemRepo.model.create( + [ + { + returnOrderId: returnOrderDoc._id, + orderItem: item.orderItemId, + shipmentItem: item.shipmentItemId, + quantity: item.quantity, + comment: item.comment, + reason: item.reason, + imagesUrl: item.imagesUrl, + }, + ], + { session }, + ); + + shipmentItem.returned_quantity! += item.quantity; + if (shipmentItem.returned_quantity === shipmentItem.quantity) orderItem.status = OrderItemsStatus.Returned; + + orderItem.save({ session }); + } + + returnOrderDoc.total_price = total_return_price; + await returnOrderDoc.save({ session }); + + await session.commitTransaction(); + await session.endSession(); + + return { returnOrder: returnOrder[0] }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + + //####################################################### + //####################################################### + async approveReturnRequest(returnOrderId: string) { + const session = await startSession(); + session.startTransaction(); + try { + // update ReturnOrder status + const returnOrder = await this.returnOrderRepo.model.findByIdAndUpdate(returnOrderId, { status: StatusEnum.Approved }, { session }); + if (!returnOrder) throw new BadRequestError(OrderMessage.ReturnOrderNotFound); + + const user = await this.userRepository.findById(returnOrder.user.toString()); + if (!user) throw new BadRequestError(UserMessage.UserNotFound); + + // fetch ReturnOrderItems + const returnItems = await this.returnOrderItemRepo.model.find({ returnOrderId }).session(session); + if (!returnItems || returnItems.length < 1) throw new BadRequestError(OrderMessage.ReturnOrderItemsNotFound); + + for (const returnItem of returnItems) { + // update ReturnOrderItem status + await this.returnOrderItemRepo.model.findByIdAndUpdate(returnItem._id, { status: StatusEnum.Approved }, { session }); + + // update ShipmentItem's returned_quantity + const orderItem = await this.orderItemRepo.model.findById(returnItem.orderItem).session(session); + if (!orderItem) throw new BadRequestError(OrderMessage.OrderItemsNotFound); + + const shipmentItem = orderItem.shipmentItems.find((shipItem) => shipItem._id?.equals(returnItem.shipmentItem)); + + if (!shipmentItem) throw new BadRequestError(OrderMessage.ShipmentItemNotFound); + + const shop = await this.shopRepo.model.findById(orderItem.shop).session(session); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + shipmentItem.returned_quantity! += returnItem.quantity; + await this.notificationService.notifyOrderReturned(shop.owner.toString(), orderItem.order, session, returnItem.imagesUrl); + + await orderItem.save({ session }); + } + + await SMS.sendOrderReturnedSms(user.phoneNumber as string, user.fullName, returnOrder.total_price, +returnOrder.order); + await session.commitTransaction(); + await session.endSession(); + + return { + message: OrderMessage.ReturnApproved, + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + //####################################################### + //####################################################### + async rejectReturnRequest(returnOrderId: string) { + await this.returnOrderRepo.model.findByIdAndUpdate(returnOrderId, { status: StatusEnum.Rejected }); + await this.returnOrderItemRepo.model.updateMany({ returnOrderId }, { status: StatusEnum.Rejected }); + return { + message: OrderMessage.ReturnRejected, + }; + } + + //####################################################### + //####################################################### + async completeReturnRequest(returnOrderId: string) { + const session = await startSession(); + session.startTransaction(); + try { + // update returnOrder status to 'completed' + const returnOrder = await this.returnOrderRepo.model.findByIdAndUpdate(returnOrderId, { status: StatusEnum.Completed }, { session }); + if (!returnOrder) throw new BadRequestError(OrderMessage.ReturnOrderNotFound); + + // Fetch ReturnOrderItems + const returnItems = await this.returnOrderItemRepo.model.find({ returnOrderId }).session(session); + if (!returnItems || returnItems.length < 1) throw new BadRequestError(OrderMessage.ReturnOrderItemsNotFound); + + for (const returnItem of returnItems) { + await this.returnOrderRepo.model.findByIdAndUpdate(returnItem._id, { status: StatusEnum.Completed }, { session }); + + //TODO:should handle refund processing + } + + await session.commitTransaction(); + await session.endSession(); + return { + message: OrderMessage.ReturnCompleted, + }; + } catch (error) { + await session.abortTransaction(); + await session.endSession(); + throw error; + } + } + //####################################################### + //####################################################### + async getReturnReasons() { + const reasons = await this.returnReasonRep.findAll(); + return { reasons }; + } + //####################################################### + //####################################################### + async createReturnReason(reasonDto: ReasonDTO) { + const fineRule = await this.fineRuleRepo.findById(reasonDto.fineRule); + const newReason = await this.returnReasonRep.model.create(reasonDto); + return { + message: CommonMessage.Created, + fineRule, + newReason, + }; + } + //####################################################### + //####################################################### + async updateReturnReason(reasonId: string, updateReasonDto: UpdateReasonDTO) { + if (!isValidObjectId(reasonId)) throw new BadRequestError(CommonMessage.NotValidId); + + const updatedReason = await this.returnReasonRep.model.findByIdAndUpdate(reasonId, updateReasonDto, { new: true }); + if (!updatedReason) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Updated, + updatedReason, + }; + } + //####################################################### + //####################################################### + async deleteReturnReason(reasonId: string) { + if (!isValidObjectId(reasonId)) throw new BadRequestError(CommonMessage.NotValidId); + await this.returnReasonRep.model.findByIdAndDelete(reasonId); + return { + message: CommonMessage.Deleted, + }; + } +} + +export { ReturnService }; diff --git a/src/modules/seller/DTO/complete-register.dto.ts b/src/modules/seller/DTO/complete-register.dto.ts new file mode 100644 index 0000000..cc6bce5 --- /dev/null +++ b/src/modules/seller/DTO/complete-register.dto.ts @@ -0,0 +1,94 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsString, Length, MinLength } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { AuthMessage, CommonMessage, SellerMessage } from "../../../common/enums/message.enum"; +import { BusinessTypeEnum, CompanyTypeEnum } from "../enum/seller.enum"; + +export class CompleteRegistrationDTO { + @Expose() + @IsString() + @MinLength(6) + @IsNotEmpty({ message: CommonMessage.NameNotEmpty }) + @ApiProperty({ type: "string", description: "fullname of seller", example: "مهیار گودرزی" }) + fullName: string; + + @Expose() + @IsString() + @IsNotEmpty() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "data of birth", example: "1402/02/25" }) + dateOfBirth: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email of seller", example: "shop@email.com" }) + email: string; + + @Expose() + @IsNotEmpty() + @IsEnum(BusinessTypeEnum) + @ApiProperty({ type: "string", description: "the business type of seller ", example: BusinessTypeEnum.LEGAL }) + business_type: BusinessTypeEnum; + + @Expose() + @IsNotEmpty() + @IsString() + @MinLength(3) + @ApiProperty({ type: "string", description: "the name of company", example: "لورم ایپسوم" }) + companyName: string; +} + +export class CompleteRespirationRealDTO extends CompleteRegistrationDTO { + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.NationalCodeIncorrect }) + @ApiProperty({ type: "string", description: "iranian format (10 char)", example: "4569852169" }) + @Length(10, 10, { message: CommonMessage.NationalCodeIncorrect }) + nationalCode: string; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.CardNumberIncorrect }) + @Length(16, 16, { message: SellerMessage.CardNumberIncorrect }) + @ApiProperty({ type: "string", description: "the card number of seller", example: "5896541236547896" }) + cardNumber: string; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.ShebaIncorrect }) + @Length(24, 24, { message: CommonMessage.ShebaIncorrect }) + @ApiProperty({ type: "string", description: "the sheba number without IR (24 char)", example: "456985478562145879652145" }) + shebaNumber: string; +} + +export class CompleteRespirationLegalDTO extends CompleteRegistrationDTO { + @Expose() + @IsNotEmpty() + @IsEnum(CompanyTypeEnum) + @ApiProperty({ type: "string", description: "the company type", example: CompanyTypeEnum.COOPERATIVE }) + companyType: CompanyTypeEnum; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.ShopIdIncorrect }) + @ApiProperty({ type: "string", description: "the shop national id code (11 char)==> شناسه شرکت", example: "19354687915" }) + @Length(11, 11, { message: SellerMessage.ShopIdIncorrect }) + companyNationalId: string; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.ShopIdIncorrect }) + @ApiProperty({ type: "string", description: "the shop national id code (11 char)==> شناسه شرکت", example: "19354687915" }) + @Length(11, 11, { message: SellerMessage.ShopIdIncorrect }) + companyEconomicNumber?: string; + + @Expose() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.ShebaIncorrect }) + @Length(24, 24, { message: CommonMessage.ShebaIncorrect }) + @ApiProperty({ type: "string", description: "the sheba number without IR (24 char)", example: "456985478562145879652145" }) + IBan: string; +} diff --git a/src/modules/seller/DTO/createDocumentType.ts b/src/modules/seller/DTO/createDocumentType.ts new file mode 100644 index 0000000..0812a58 --- /dev/null +++ b/src/modules/seller/DTO/createDocumentType.ts @@ -0,0 +1,26 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsNotEmpty, IsString, MinLength } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateDocumentTypeDTO { + @Expose() + @IsNotEmpty() + @IsString() + @MinLength(3) + @ApiProperty({ type: "string", description: "Document title", example: "عکس پشت کارت ملی" }) + title: string; + + @Expose() + @IsNotEmpty() + @IsString() + @MinLength(3) + @ApiProperty({ type: "string", description: "Document description", example: "لطفا به صورت واضح آپلود کنید" }) + description: string; + + @Expose() + @IsNotEmpty() + @IsBoolean() + @ApiProperty({ type: "boolean", description: "specify if Document is required", example: true }) + required: boolean; +} diff --git a/src/modules/seller/DTO/sellerProductQuery.dto.ts b/src/modules/seller/DTO/sellerProductQuery.dto.ts new file mode 100644 index 0000000..7bf017d --- /dev/null +++ b/src/modules/seller/DTO/sellerProductQuery.dto.ts @@ -0,0 +1,27 @@ +import { Expose } from "class-transformer"; +import { IsOptional } from "class-validator"; + +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { PaginationDTO } from "../../../common/dto/pagination.dto"; +import { ProductStatus } from "../../../common/enums/product.enum"; +// import { TimeService } from "../../../utils/time.service"; + +export class SellerProductQueryDTO extends PaginationDTO { + @Expose() + @IsOptional() + categoryId: string; + + @Expose() + @IsOptional() + // @IsEnum(ProductStatus) + status: ProductStatus; + + @Expose() + @IsOptional() + q: string; + + @Expose() + @IsOptional() + @IsValidPersianDate() + since: string; +} diff --git a/src/modules/seller/DTO/sellerUpdate.dto.ts b/src/modules/seller/DTO/sellerUpdate.dto.ts new file mode 100644 index 0000000..e77c57b --- /dev/null +++ b/src/modules/seller/DTO/sellerUpdate.dto.ts @@ -0,0 +1,89 @@ +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsNumberString, IsOptional, IsString, Length, MinLength } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { CommonMessage, SellerMessage } from "../../../common/enums/message.enum"; +import { CompanyTypeEnum } from "../enum/seller.enum"; + +export class SellerUpdateDto { + @Expose() + @IsOptional() + @IsNotEmpty({ message: CommonMessage.NameNotEmpty }) + @ApiProperty({ type: "string", description: "fullname of seller", example: "مهیار گودرزی" }) + fullName?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "data of birth", example: "1402/02/25" }) + dateOfBirth?: string; +} + +export class RealSellerUpdateDto extends SellerUpdateDto { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.NationalCodeIncorrect }) + @ApiProperty({ type: "string", description: "iranian format (10 char)", example: "4569852169" }) + @Length(10, 10, { message: CommonMessage.NationalCodeIncorrect }) + nationalCode?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.CardNumberIncorrect }) + @Length(16, 16, { message: SellerMessage.CardNumberIncorrect }) + @ApiProperty({ type: "string", description: "the card number of seller", example: "5896541236547896" }) + cardNumber?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.ShebaIncorrect }) + @Length(24, 24, { message: CommonMessage.ShebaIncorrect }) + @ApiProperty({ type: "string", description: "the sheba number without IR (24 char)", example: "456985478562145879652145" }) + shebaNumber?: string; +} + +export class LegalSellerUpdateDto extends SellerUpdateDto { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @MinLength(3) + @ApiProperty({ type: "string", description: "the name of company", example: "لورم ایپسوم" }) + companyName?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsEnum(CompanyTypeEnum) + @ApiProperty({ type: "string", description: "the company type", example: CompanyTypeEnum.COOPERATIVE }) + companyType?: CompanyTypeEnum; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.ShopIdIncorrect }) + @ApiProperty({ type: "string", description: "the shop national id code (11 char)==> شناسه شرکت", example: "19354687915" }) + @Length(11, 11, { message: SellerMessage.ShopIdIncorrect }) + companyNationalId?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: SellerMessage.ShopIdIncorrect }) + @ApiProperty({ type: "string", description: "the shop national id code (11 char)==> شناسه شرکت", example: "19354687915" }) + @Length(11, 11, { message: SellerMessage.ShopIdIncorrect }) + companyEconomicNumber?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.ShebaIncorrect }) + @Length(24, 24, { message: CommonMessage.ShebaIncorrect }) + @ApiProperty({ type: "string", description: "the sheba number without IR (24 char)", example: "456985478562145879652145" }) + IBan?: string; +} diff --git a/src/modules/seller/DTO/update-sellerDocument.dto.ts b/src/modules/seller/DTO/update-sellerDocument.dto.ts new file mode 100644 index 0000000..31d9a54 --- /dev/null +++ b/src/modules/seller/DTO/update-sellerDocument.dto.ts @@ -0,0 +1,28 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { DocumentTypeModel } from "../models/documentType.mode"; +// import { SellerDocumentModel } from "../models/sellerDocument.model"; + +export class UploadSellerDocumentDTO { + // @Expose() + // @IsOptional() + // @IsNotEmpty() + // @IsValidId(SellerDocumentModel) + // @ApiProperty({ type: "string", description: "id of the seller docs (optional)", example: "60f4b3b3b3f3b4f4b3b3b3f3" }) + // docsId?: string; + + @Expose() + @IsNotEmpty() + @IsValidId(DocumentTypeModel) + @ApiProperty({ type: "string", description: "id of the document type", example: "60f4b3b3b3f3b4f4b3b3b3f3" }) + documentTypeId: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "address of the uploaded docs", example: "https://example.com/default.png" }) + docsUrl: string; +} diff --git a/src/modules/seller/DTO/update-shop-shipment.dto.ts b/src/modules/seller/DTO/update-shop-shipment.dto.ts new file mode 100644 index 0000000..470bf3e --- /dev/null +++ b/src/modules/seller/DTO/update-shop-shipment.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer"; +import { IsInt, IsNotEmpty } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ShipmentModel } from "../../shipment/models/shipment.model"; + +export class UpdateShopShipmentDTO { + @Expose() + @IsNotEmpty() + @IsInt() + @IsValidId(ShipmentModel) + @ApiProperty({ type: "number", description: "the id of shipper", example: 123 }) + shipmentId: number; +} diff --git a/src/modules/seller/enum/seller.enum.ts b/src/modules/seller/enum/seller.enum.ts new file mode 100644 index 0000000..aa330bf --- /dev/null +++ b/src/modules/seller/enum/seller.enum.ts @@ -0,0 +1,22 @@ +export enum BusinessTypeEnum { + REAL = "real", + LEGAL = "legal", +} + +export enum CompanyTypeEnum { + PUBLIC = "public", + PRIVATE = "private", + LIMITED_LIABILITY = "limited_liability", + COOPERATIVE = "cooperative", + PARTNERSHIP = "partnership", + INSTITUTE = "institute", + OTHER = "other", +} + +// سهامی عام +// سهامی خاص +// مسئولیت محدود +// تعاونی +// تضامنی +// موسسه +// سایر diff --git a/src/modules/seller/models/Abstraction/IDocumentType.ts b/src/modules/seller/models/Abstraction/IDocumentType.ts new file mode 100644 index 0000000..fe0954c --- /dev/null +++ b/src/modules/seller/models/Abstraction/IDocumentType.ts @@ -0,0 +1,5 @@ +export interface IDocumentType { + title: string; + description: string; + required: boolean; +} diff --git a/src/modules/seller/models/Abstraction/ILegal-seller.ts b/src/modules/seller/models/Abstraction/ILegal-seller.ts new file mode 100644 index 0000000..37cb182 --- /dev/null +++ b/src/modules/seller/models/Abstraction/ILegal-seller.ts @@ -0,0 +1,12 @@ +import { Types } from "mongoose"; + +import { CompanyTypeEnum } from "../../enum/seller.enum"; + +export class ILegalSeller { + seller: Types.ObjectId; + companyName: string; + companyType: CompanyTypeEnum; + companyNationalId: string; + companyEconomicNumber: string; + IBan: string; +} diff --git a/src/modules/seller/models/Abstraction/IReal-seller.ts b/src/modules/seller/models/Abstraction/IReal-seller.ts new file mode 100644 index 0000000..44e410f --- /dev/null +++ b/src/modules/seller/models/Abstraction/IReal-seller.ts @@ -0,0 +1,8 @@ +import { Types } from "mongoose"; + +export interface IRealSeller { + seller: Types.ObjectId; + nationalCode: string; + cardNumber: string; + shebaNumber: string; +} diff --git a/src/modules/seller/models/Abstraction/ISeller-contract.ts b/src/modules/seller/models/Abstraction/ISeller-contract.ts new file mode 100644 index 0000000..d42de09 --- /dev/null +++ b/src/modules/seller/models/Abstraction/ISeller-contract.ts @@ -0,0 +1,9 @@ +import { Types } from "mongoose"; + +export class ISellerContract { + content: string; + seller: Types.ObjectId; + contractNumber: string; + signed: boolean; + singedAt: string; +} diff --git a/src/modules/seller/models/Abstraction/ISeller-document.ts b/src/modules/seller/models/Abstraction/ISeller-document.ts new file mode 100644 index 0000000..5958168 --- /dev/null +++ b/src/modules/seller/models/Abstraction/ISeller-document.ts @@ -0,0 +1,11 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export class ISellerDocument { + seller: Types.ObjectId; + type: Types.ObjectId; + status: StatusEnum; + docsUrl: string; + comment: string; +} diff --git a/src/modules/seller/models/Abstraction/ISeller-learning.ts b/src/modules/seller/models/Abstraction/ISeller-learning.ts new file mode 100644 index 0000000..34f4104 --- /dev/null +++ b/src/modules/seller/models/Abstraction/ISeller-learning.ts @@ -0,0 +1,5 @@ +import { Types } from "mongoose"; + +export class ISellerLearning { + seller: Types.ObjectId; +} diff --git a/src/modules/seller/models/Abstraction/ISeller-status.ts b/src/modules/seller/models/Abstraction/ISeller-status.ts new file mode 100644 index 0000000..f15482d --- /dev/null +++ b/src/modules/seller/models/Abstraction/ISeller-status.ts @@ -0,0 +1,9 @@ +import { Types } from "mongoose"; + +export interface ISellerStatus { + seller: Types.ObjectId; + document: boolean; + contract: boolean; + learning: boolean; + register: boolean; +} diff --git a/src/modules/seller/models/Abstraction/ISeller.ts b/src/modules/seller/models/Abstraction/ISeller.ts new file mode 100644 index 0000000..039a333 --- /dev/null +++ b/src/modules/seller/models/Abstraction/ISeller.ts @@ -0,0 +1,16 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; +import { BusinessTypeEnum } from "../../enum/seller.enum"; + +export interface ISeller { + _id: Types.ObjectId; + fullName: string; + dateOfBirth: string; + phoneNumber: string; + email: string; + accountStatus: StatusEnum; + isRegisterCompleted: boolean; + businessType: BusinessTypeEnum; + isWholesaler: boolean; +} diff --git a/src/modules/seller/models/Abstraction/IWholesaleRequest.ts b/src/modules/seller/models/Abstraction/IWholesaleRequest.ts new file mode 100644 index 0000000..273e40a --- /dev/null +++ b/src/modules/seller/models/Abstraction/IWholesaleRequest.ts @@ -0,0 +1,9 @@ +import { Types } from "mongoose"; + +import { StatusEnum } from "../../../../common/enums/status.enum"; + +export interface IWholesaleRequest { + seller: Types.ObjectId; + shop: Types.ObjectId; + status: StatusEnum; +} diff --git a/src/modules/seller/models/documentType.mode.ts b/src/modules/seller/models/documentType.mode.ts new file mode 100644 index 0000000..84a70f1 --- /dev/null +++ b/src/modules/seller/models/documentType.mode.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { IDocumentType } from "./Abstraction/IDocumentType"; + +const DocumentTypeSchema = new Schema( + { + title: { type: String, required: true }, + description: { type: String, required: true }, + required: { type: Boolean, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const DocumentTypeModel = model("DocumentType", DocumentTypeSchema); + +export { DocumentTypeModel }; diff --git a/src/modules/seller/models/legalSeller.model.ts b/src/modules/seller/models/legalSeller.model.ts new file mode 100644 index 0000000..f984ec1 --- /dev/null +++ b/src/modules/seller/models/legalSeller.model.ts @@ -0,0 +1,22 @@ +import { Schema, model } from "mongoose"; + +import { ILegalSeller } from "./Abstraction/ILegal-seller"; + +const LegalSellerSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + companyName: { type: String, required: true, unique: true }, + companyType: { type: String, required: true }, + companyNationalId: { type: String, required: true, unique: true }, + companyEconomicNumber: { type: String, required: true, unique: true }, + IBan: { type: String, required: true, unique: true }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +const LegalSellerModel = model("LegalSeller", LegalSellerSchema); +export { LegalSellerModel }; diff --git a/src/modules/seller/models/realSeller.model.ts b/src/modules/seller/models/realSeller.model.ts new file mode 100644 index 0000000..6785c66 --- /dev/null +++ b/src/modules/seller/models/realSeller.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { IRealSeller } from "./Abstraction/IReal-seller"; + +const RealSellerSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + nationalCode: { type: String, required: true, unique: true }, + cardNumber: { type: String, required: true, unique: true }, + shebaNumber: { type: String, required: true, unique: true }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +const RealSellerModel = model("RealSeller", RealSellerSchema); + +export { RealSellerModel }; diff --git a/src/modules/seller/models/seller.model.ts b/src/modules/seller/models/seller.model.ts new file mode 100644 index 0000000..db38be0 --- /dev/null +++ b/src/modules/seller/models/seller.model.ts @@ -0,0 +1,25 @@ +import { Schema, model } from "mongoose"; + +import { ISeller } from "./Abstraction/ISeller"; +// import { SellerType } from "../../../common/enums/seller.enum"; +import { StatusEnum } from "../../../common/enums/status.enum"; +import { BusinessTypeEnum } from "../enum/seller.enum"; + +const sellerSchema = new Schema( + { + fullName: { type: String, required: true, trim: true }, + dateOfBirth: { type: String, default: null }, + phoneNumber: { type: String, unique: true, sparse: true }, + email: { type: String, unique: true, sparse: true, trim: true }, + accountStatus: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + // type: { type: String, enum: SellerType, default: SellerType.RETAILER }, + isRegisterCompleted: { type: Boolean, default: false }, + businessType: { type: String, enum: BusinessTypeEnum, default: null }, + isWholesaler: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); + +const SellerModel = model("Seller", sellerSchema); + +export { SellerModel }; diff --git a/src/modules/seller/models/sellerContract.model.ts b/src/modules/seller/models/sellerContract.model.ts new file mode 100644 index 0000000..bceba77 --- /dev/null +++ b/src/modules/seller/models/sellerContract.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { ISellerContract } from "./Abstraction/ISeller-contract"; + +const SellerContractSchema = new Schema( + { + content: { type: String, required: true }, + seller: { type: Schema.Types.ObjectId, ref: "Seller", unique: true, required: true }, + contractNumber: { type: String, required: true, unique: true }, + signed: { type: Boolean, default: false }, + singedAt: { type: String, default: null }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); +const SellerContractModel = model("SellerContract", SellerContractSchema); + +export { SellerContractModel }; diff --git a/src/modules/seller/models/sellerDocument.model.ts b/src/modules/seller/models/sellerDocument.model.ts new file mode 100644 index 0000000..127e86a --- /dev/null +++ b/src/modules/seller/models/sellerDocument.model.ts @@ -0,0 +1,18 @@ +import { Schema, model } from "mongoose"; + +import { ISellerDocument } from "./Abstraction/ISeller-document"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const SellerDocumentSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + type: { type: Schema.Types.ObjectId, ref: "DocumentType", required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + docsUrl: { type: String, required: true }, + comment: { type: String, default: null }, + }, + { timestamps: true, toJSON: { virtuals: true, versionKey: false }, id: false }, +); +const SellerDocumentModel = model("SellerDocument", SellerDocumentSchema); + +export { SellerDocumentModel }; diff --git a/src/modules/seller/models/sellerStatus.model.ts b/src/modules/seller/models/sellerStatus.model.ts new file mode 100644 index 0000000..030ec52 --- /dev/null +++ b/src/modules/seller/models/sellerStatus.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { ISellerStatus } from "./Abstraction/ISeller-status"; + +const SellerStatusSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + contract: { type: Boolean, default: false }, + document: { type: Boolean, default: false }, + learning: { type: Boolean, default: false }, + register: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +const SellerStatusModel = model("SellerStatus", SellerStatusSchema); +export { SellerStatusModel }; diff --git a/src/modules/seller/models/wholesaleRequest.model.ts b/src/modules/seller/models/wholesaleRequest.model.ts new file mode 100644 index 0000000..4d23cfe --- /dev/null +++ b/src/modules/seller/models/wholesaleRequest.model.ts @@ -0,0 +1,17 @@ +import { Schema, model } from "mongoose"; + +import { IWholesaleRequest } from "./Abstraction/IWholesaleRequest"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +const WholesaleRequestSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + shop: { type: Schema.Types.ObjectId, ref: "Shop", required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + }, + { timestamps: true, toJSON: { versionKey: false }, id: false }, +); + +WholesaleRequestSchema.index({ seller: 1, shop: 1 }, { unique: true }); +const WholesaleRequestModel = model("WholesaleRequest", WholesaleRequestSchema); +export { WholesaleRequestModel }; diff --git a/src/modules/seller/repository/documentType.repository.ts b/src/modules/seller/repository/documentType.repository.ts new file mode 100644 index 0000000..7cea7e9 --- /dev/null +++ b/src/modules/seller/repository/documentType.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IDocumentType } from "../models/Abstraction/IDocumentType"; +import { DocumentTypeModel } from "../models/documentType.mode"; + +export class DocumentTypeRepo extends BaseRepository { + constructor() { + super(DocumentTypeModel); + } +} + +export function createDocumentTypeRepo(): DocumentTypeRepo { + return new DocumentTypeRepo(); +} diff --git a/src/modules/seller/repository/legalSeller.repository.ts b/src/modules/seller/repository/legalSeller.repository.ts new file mode 100644 index 0000000..562e381 --- /dev/null +++ b/src/modules/seller/repository/legalSeller.repository.ts @@ -0,0 +1,12 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ILegalSeller } from "../models/Abstraction/ILegal-seller"; +import { LegalSellerModel } from "../models/legalSeller.model"; + +export class LegalSellerRepo extends BaseRepository { + constructor() { + super(LegalSellerModel); + } +} +export function createLegalSellerRepo(): LegalSellerRepo { + return new LegalSellerRepo(); +} diff --git a/src/modules/seller/repository/realSeller.repository.ts b/src/modules/seller/repository/realSeller.repository.ts new file mode 100644 index 0000000..a0ca1b8 --- /dev/null +++ b/src/modules/seller/repository/realSeller.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IRealSeller } from "../models/Abstraction/IReal-seller"; +import { RealSellerModel } from "../models/realSeller.model"; + +export class RealSellerRepo extends BaseRepository { + constructor() { + super(RealSellerModel); + } +} + +export function createRealSellerRepo(): RealSellerRepo { + return new RealSellerRepo(); +} diff --git a/src/modules/seller/repository/sellerContract.repository.ts b/src/modules/seller/repository/sellerContract.repository.ts new file mode 100644 index 0000000..96b5e39 --- /dev/null +++ b/src/modules/seller/repository/sellerContract.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ISellerContract } from "../models/Abstraction/ISeller-contract"; +import { SellerContractModel } from "../models/sellerContract.model"; + +export class SellerContractRepo extends BaseRepository { + constructor() { + super(SellerContractModel); + } +} + +export function createSellerContractRepo(): SellerContractRepo { + return new SellerContractRepo(); +} diff --git a/src/modules/seller/repository/sellerDocument.repository.ts b/src/modules/seller/repository/sellerDocument.repository.ts new file mode 100644 index 0000000..3cced70 --- /dev/null +++ b/src/modules/seller/repository/sellerDocument.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ISellerDocument } from "../models/Abstraction/ISeller-document"; +import { SellerDocumentModel } from "../models/sellerDocument.model"; + +export class SellerDocumentRepo extends BaseRepository { + constructor() { + super(SellerDocumentModel); + } +} + +export function createSellerDocumentRepo(): SellerDocumentRepo { + return new SellerDocumentRepo(); +} diff --git a/src/modules/seller/repository/sellerStatus.repository.ts b/src/modules/seller/repository/sellerStatus.repository.ts new file mode 100644 index 0000000..c4a655b --- /dev/null +++ b/src/modules/seller/repository/sellerStatus.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ISellerStatus } from "../models/Abstraction/ISeller-status"; +import { SellerStatusModel } from "../models/sellerStatus.model"; + +export class SellerStatusRepo extends BaseRepository { + constructor() { + super(SellerStatusModel); + } +} + +export function createSellerStatusRepo(): SellerStatusRepo { + return new SellerStatusRepo(); +} diff --git a/src/modules/seller/repository/wholesaleRequest.repositroy.ts b/src/modules/seller/repository/wholesaleRequest.repositroy.ts new file mode 100644 index 0000000..fbe7c22 --- /dev/null +++ b/src/modules/seller/repository/wholesaleRequest.repositroy.ts @@ -0,0 +1,17 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IWholesaleRequest } from "../models/Abstraction/IWholesaleRequest"; +import { WholesaleRequestModel } from "../models/wholesaleRequest.model"; + +export class WholesaleRequestRepo extends BaseRepository { + constructor() { + super(WholesaleRequestModel); + } + + async getWholesaleRequestCount() { + return this.model.countDocuments(); + } +} + +export function createWholesaleRequestRepo(): WholesaleRequestRepo { + return new WholesaleRequestRepo(); +} diff --git a/src/modules/seller/seller.controller.ts b/src/modules/seller/seller.controller.ts new file mode 100644 index 0000000..30ec2b0 --- /dev/null +++ b/src/modules/seller/seller.controller.ts @@ -0,0 +1,439 @@ +import { Request } from "express"; +// eslint-disable-next-line import/no-named-as-default +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPatch, httpPost, queryParam, request, requestBody, requestParam } from "inversify-express-utils"; + +import { LegalSellerUpdateDto, RealSellerUpdateDto } from "./DTO/sellerUpdate.dto"; +import { HttpStatus } from "../../common"; +import { CompleteRespirationLegalDTO, CompleteRespirationRealDTO } from "./DTO/complete-register.dto"; +import { SellerProductQueryDTO } from "./DTO/sellerProductQuery.dto"; +import { UploadSellerDocumentDTO } from "./DTO/update-sellerDocument.dto"; +import { ISeller } from "./models/Abstraction/ISeller"; +import { SellerService } from "./seller.service"; +import { BaseController } from "../../common/base/controller"; +import { + ApiAuth, + ApiBody, + ApiFile, + ApiModel, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from "../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../common/enums/message.enum"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { AuthService } from "../auth/auth.service"; +import { AuthDTO } from "../auth/DTO/Auth.dto"; +import { AuthCheckOtpDTO } from "../auth/DTO/AuthCheckOtp.dto"; +import { TokenDto } from "../auth/DTO/Token.dto"; +import { OrderService } from "../order/order.service"; +import { ProductRequestService } from "../product/providers/product-request.service"; +import { ProductService } from "../product/providers/product.service"; +import { ShopUpdateDTO } from "../shop/DTO/shop-update.dto"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopService } from "../shop/shop.service"; +import { UpdateShopShipmentDTO } from "./DTO/update-shop-shipment.dto"; +import { BusinessTypeEnum } from "./enum/seller.enum"; + +@controller("/seller") +@ApiTags("Seller") +class SellerController extends BaseController { + @inject(IOCTYPES.SellerService) sellerService: SellerService; + @inject(IOCTYPES.AuthService) authService: AuthService; + @inject(IOCTYPES.ProductService) productService: ProductService; + @inject(IOCTYPES.OrderService) orderService: OrderService; + @inject(IOCTYPES.ShopService) shopService: ShopService; + @inject(IOCTYPES.ProductRequestService) productRequestService: ProductRequestService; + + // @ApiOperation("get all sellers data") + // @httpGet("") + // public async getSellers() { + // const data = await this.sellerService.getSellers(); + // return this.response({ data }); + // } + + @ApiOperation("get all sellers data") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/panel", Guard.authSeller()) + public async sellerPanel(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getSellerPanelInfo(seller._id.toString()); + return this.response(data); + } + + //get orders for seller panel + @ApiOperation("get all orders of current session seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("shipperId", "shipper id") + @ApiQuery("status", "status of payment ==> value = Completed | Cancelled | Pending ") + @ApiQuery( + "since", + "time of order created ==> value should be standard timestamp since that time user want like == > Date.now() - (7 * 24 * 60 * 60 * 1000) ==> this mean from a week ago till now ", + ) + @ApiQuery("maxPrice", "search based on payment priceRange") + @ApiQuery("minPrice", "search based on payment priceRange") + @ApiQuery("orderIds", "Array of order IDs to fetch", false, "array") + @ApiAuth() + @httpGet("/panel/orders", Guard.authSeller()) + public async getOrders( + @request() req: Request, + @queryParam("limit") limit: string, + @queryParam("page") page: string, + @queryParam("shipperId") shipperId: string, + @queryParam("status") status: string, + @queryParam("since") since: string, + @queryParam("maxPrice") maxPrice: string, + @queryParam("minPrice") minPrice: string, + @queryParam("orderIds") orderIds: string[], + ) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + shipperId, + status, + since: +since, + maxPrice: +maxPrice, + minPrice: +minPrice, + }; + const seller = req.user as ISeller; + const { priceRange, ...data } = await this.orderService.getOrders(seller._id.toString(), queries, orderIds); + const { pager } = this.paginate(data.count); + return this.response({ pager, orders: data.orders, priceRange }); + } + + //############################################################### + //############################################################### + //get product for seller panel + @ApiOperation("get all products of current session seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("categoryId", "category id") + @ApiQuery("status", "status of product ==> value = Approved | Rejected | Pending ") + @ApiQuery("q", "q is the product variant id or special code of variant that seller specify") + @ApiQuery("since", "time of product created ==> value should be standard shamsi date since that time user want like == > 1400-01-01") + @ApiAuth() + @httpGet("/panel/products", Guard.authSeller(), ValidationMiddleware.validateQuery(SellerProductQueryDTO)) + public async getSellerProduct(@request() req: Request, @queryParam() queryDto: SellerProductQueryDTO) { + const seller = req.user as ISeller; + const data = await this.productService.findSellerProduct(seller._id.toString(), queryDto); + const { pager } = this.paginate(data.count); + return this.response({ pager, products: data.products, brands: data.brands, categories: data.categories }); + } + + @ApiOperation("get seller statuses") + @ApiAuth() + @httpGet("/panel/status", Guard.authSeller()) + public async getSellerStatus(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getSellerStatus(seller._id.toString()); + return this.response(data); + } + + //############################################################### + //############################################################### + //get product of seller that is in the draft status + + @ApiOperation("get product requests of seller") + @ApiResponse("successfully") + @ApiAuth() + @httpGet("/panel/products/request", Guard.authSeller()) + public async getSellerProductRequests(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.productRequestService.getProductRequests(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get single product request details") + @ApiResponse("successfully") + @ApiParam("id", "the product id", true) + @ApiAuth() + @httpGet("/panel/products/request/:id", Guard.authSeller()) + public async getSellerProductRequestDetails(@request() req: Request, @requestParam("id") requestId: string) { + const seller = req.user as ISeller; + const data = await this.productRequestService.getProductRequestDetails(seller._id.toString(), requestId); + return this.response(data); + } + + @ApiOperation("get single product details") + @ApiResponse("successfully") + @ApiParam("id", "the product id", true) + @ApiAuth() + @httpGet("/panel/products/:id", Guard.authSeller()) + public async getSingleProduct(@request() req: Request, @requestParam("id") productId: string) { + const seller = req.user as ISeller; + const data = await this.productService.getProductDetailsForSellerPanel(seller._id.toString(), +productId); + return this.response(data); + } + + //################################################################# + //################################################################# + @ApiOperation("get sale-stats of current session seller") + @ApiResponse("successfully", HttpStatus.Ok) + @ApiQuery("start", "the start date of stats range") + @ApiQuery("end", "the end data of stats range") + @ApiAuth() + @httpGet("/panel/sales-stats", Guard.authSeller()) + public async getSellerSalesStats(@request() req: Request, @queryParam("start") start: string, @queryParam("end") end: string) { + const queries = { + start, + end, + }; + const seller = req.user as ISeller; + const data = await this.orderService.getSellerSalesStats(seller._id.toString(), queries); + return this.response(data); + } + + //############################################################### + //############################################################## + @ApiOperation("Authenticate to send OTP code") + @ApiResponse("Successful", 200, { message: AuthMessage.OtpSentToNo, phone: "09122569856" }) + @ApiResponse("BadRequest", 400, { details: ["فرمت موبایل اشتباه است"] }) + @ApiModel(AuthDTO) + @ApiBody("Authenticate with phone or email") + @httpPost("/authenticate", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(AuthDTO)) + async authSeller(@requestBody() authDto: AuthDTO) { + const data = await this.authService.authenticate(authDto, "seller"); + return this.response({ data }, HttpStatus.Ok); + } + + @ApiOperation("login seller -> check sent OTP and generate the token") + @ApiResponse("Successful login", 200) + @ApiModel(AuthCheckOtpDTO) + @ApiBody("login seller with otp code") + @httpPost("/login/otp", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(AuthCheckOtpDTO)) + async loginOtp(@requestBody() loginOtpDto: AuthCheckOtpDTO) { + const data = await this.authService.loginOtpS(loginOtpDto, "seller"); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("complete seller registration step 3") + @ApiResponse("Successful", 200) + @ApiModel(CompleteRespirationLegalDTO) + @ApiAuth() + @httpPost("/complete-register/legal", Guard.authSeller(), ValidationMiddleware.validateInput(CompleteRespirationLegalDTO)) + async completeRegistrationLegal(@request() req: Request, @requestBody() completeDto: CompleteRespirationLegalDTO) { + const seller = req.user as ISeller; + const data = await this.sellerService.completeRegistrationLegal(seller._id.toString(), completeDto); + return this.response(data); + } + + @ApiOperation("complete seller registration step 3") + @ApiResponse("Successful", 200) + @ApiModel(CompleteRespirationRealDTO) + @ApiAuth() + @httpPost("/complete-register/real", Guard.authSeller(), ValidationMiddleware.validateInput(CompleteRespirationRealDTO)) + async completeRegistrationReal(@request() req: Request, @requestBody() completeDto: CompleteRespirationRealDTO) { + const seller = req.user as ISeller; + const data = await this.sellerService.completeRegistrationReal(seller._id.toString(), completeDto); + return this.response(data); + } + + @ApiOperation("check the refresh token and generate access token base on that") + @ApiResponse("Successful", 200) + @ApiResponse("unauthorized", 401) + @ApiResponse("notFound", 404) + @ApiModel(TokenDto) + @httpPost("/token", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(TokenDto)) + public async refreshTokens(@requestBody() refreshToken: TokenDto) { + const data = await this.authService.refreshTokensS(refreshToken); + //return new tokens + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("logout the seller based on the sent authorization token") + @ApiResponse("Successful", 200, { message: AuthMessage.LoggedOut }) + @ApiAuth() + @httpPost("/logout", rateLimit(appConfig.rate), Guard.authSeller()) + public async logout(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.authService.logoutS(seller._id.toString()); + return this.response({ data }); + } + //############################################################## + @ApiOperation("activate the shop shipment method") + @ApiModel(UpdateShopShipmentDTO) + @ApiAuth() + @httpPatch("/shipment/activate", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateShopShipmentDTO)) + public async activateShopShipper(@request() req: Request, @requestBody() updateDto: UpdateShopShipmentDTO) { + const seller = req.user as ISeller; + const data = await this.sellerService.updateShopShipper(seller._id.toString(), updateDto, "activate"); + return this.response(data); + } + + @ApiOperation("deactivate the shop shipment method") + @ApiModel(UpdateShopShipmentDTO) + @ApiAuth() + @httpPatch("/shipment/deactivate", Guard.authSeller(), ValidationMiddleware.validateInput(UpdateShopShipmentDTO)) + public async deactivateShopShipper(@request() req: Request, @requestBody() updateDto: UpdateShopShipmentDTO) { + const seller = req.user as ISeller; + const data = await this.sellerService.updateShopShipper(seller._id.toString(), updateDto, "deactivate"); + return this.response(data); + } + + @ApiOperation("change status of shop chat") + @ApiAuth() + @httpPatch("/chat/status", Guard.authSeller()) + public async changeShopChatStatus(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.changeShopChatStatus(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get all items count of the seller like notification count or ...") + @ApiAuth() + @httpGet("/items/count", Guard.authSeller()) + public async getItemsCount(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getItemsCount(seller._id.toString()); + return this.response(data); + } + + //############################################################## + + @ApiOperation("get current logged in seller info") + @ApiResponse("successful", 200) + @ApiAuth() + @httpGet("/profile/self", Guard.authSeller()) + public async getProfileInfo(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getSellerProfileInfo(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get current logged in seller shop info") + @ApiResponse("successful", 200) + @ApiAuth() + @httpGet("/profile/shop", Guard.authSeller()) + public async getShopInfo(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.shopService.getShopInfo(seller._id.toString(), OwnerRef.SELLER); + return this.response(data); + } + + @ApiOperation("update fields of the real seller info") + @ApiModel(RealSellerUpdateDto) + @ApiAuth() + @httpPatch("/profile/self/real", Guard.authSeller(), ValidationMiddleware.validateInput(RealSellerUpdateDto)) + public async updateRealSellerProfileInfo(@requestBody() updateDto: RealSellerUpdateDto, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.updateSellerInfo(BusinessTypeEnum.REAL, updateDto, seller._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update fields of the legal seller info") + @ApiModel(LegalSellerUpdateDto) + @ApiAuth() + @httpPatch("/profile/self/legal", Guard.authSeller(), ValidationMiddleware.validateInput(LegalSellerUpdateDto)) + public async updateLegalSellerProfileInfo(@requestBody() updateDto: LegalSellerUpdateDto, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.updateSellerInfo(BusinessTypeEnum.LEGAL, updateDto, seller._id.toString()); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("update fields of the seller shop") + @ApiModel(ShopUpdateDTO) + @ApiAuth() + @httpPatch("/profile/shop", Guard.authSeller(), ValidationMiddleware.validateInput(ShopUpdateDTO)) + public async updateSellerShopInfo(@requestBody() updateDto: ShopUpdateDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.shopService.updateSellerShopInfo(seller._id.toString(), updateDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get contract details") + @ApiAuth() + @httpGet("/contract", Guard.authSeller()) + public async getContract(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getContract(seller); + return this.response(data); + } + + @ApiOperation("sign contract") + @ApiParam("id", "the id of contract", true) + @ApiAuth() + @httpPost("/contract/sign/:id", Guard.authSeller()) + public async signContract(@request() req: Request, @requestParam("id") contractId: string) { + const seller = req.user as ISeller; + const data = await this.sellerService.signContract(seller._id.toString(), contractId); + return this.response(data); + } + + @ApiOperation("upload seller document") + @ApiModel(UploadSellerDocumentDTO) + @ApiAuth() + @httpPatch("/document", Guard.authSeller(), ValidationMiddleware.validateInput(UploadSellerDocumentDTO)) + public async sellerDocument(@requestBody() uploadDto: UploadSellerDocumentDTO, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.uploadSellerDocument(seller._id.toString(), uploadDto); + return this.response(data); + } + + @ApiOperation("get seller documents") + @ApiAuth() + @httpGet("/document", Guard.authSeller()) + public async getSellerDocument(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.getSellerDocument(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("get single seller document") + @ApiParam("id", "the document id", true) + @ApiAuth() + @httpGet("/document/:id", Guard.authSeller()) + public async getSingleSellerDocument(@request() req: Request, @requestParam("id") documentId: string) { + const seller = req.user as ISeller; + const data = await this.sellerService.getSingleSellerDocument(seller._id.toString(), documentId); + return this.response(data); + } + + @ApiOperation("get document types") + @httpGet("/document-types") + public async getDocumentTypes() { + const data = await this.sellerService.getDocumentTypes(); + return this.response(data); + } + + @ApiOperation("request to be a wholesale seller") + @ApiAuth() + @httpPost("/wholesale/request", Guard.authSeller()) + public async requestWholesale(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.sellerService.requestWholesale(seller._id.toString()); + return this.response(data); + } + + //uploader + @ApiOperation("Upload a seller info image") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload", Guard.authSeller(), UploadService.single("image", "seller-profile")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { SellerController }; diff --git a/src/modules/seller/seller.repository.ts b/src/modules/seller/seller.repository.ts new file mode 100644 index 0000000..027186f --- /dev/null +++ b/src/modules/seller/seller.repository.ts @@ -0,0 +1,208 @@ +import { Types } from "mongoose"; + +import { ISeller } from "./models/Abstraction/ISeller"; +import { SellerModel } from "./models/seller.model"; +import { BaseRepository } from "../../common/base/repository"; + +class SellerRepository extends BaseRepository { + constructor() { + super(SellerModel); + } + + async findByPhone(phoneNumber: string) { + return this.model.findOne({ phoneNumber }); + } + + async findByEmail(email: string) { + return this.model.findOne({ email }); + } + + async getPanelInfo(sellerId: string) { + return this.model.aggregate([ + { + $match: { + _id: new Types.ObjectId(sellerId), + }, + }, + { + $lookup: { + from: "orders", + localField: "_id", + foreignField: "sellerId", + as: "orders", + }, + }, + { + $lookup: { + from: "products", + localField: "_id", + foreignField: "seller", + as: "products", + }, + }, + { + $addFields: { + orderCount: { $size: "$orders" }, + productCount: { $size: "$products" }, + completedOrdersCount: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { $eq: ["$$order.status", "completed"] }, + }, + }, + }, + totalRevenue: { + $sum: "$orders.totalAmount", + }, + weeklyRevenue: { + $sum: { + $map: { + input: { + $filter: { + input: "$orders", + as: "order", + cond: { + $gte: ["$$order.date", new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)], + }, + }, + }, + as: "order", + in: "$$order.totalAmount", + }, + }, + }, + // Order Management Fields + ordersThisWeek: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { + $gte: ["$$order.date", new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)], + }, + }, + }, + }, + ordersToday: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { + $gte: ["$$order.date", new Date(new Date().setHours(0, 0, 0, 0))], + }, + }, + }, + }, + unapprovedOrders: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { $eq: ["$$order.status", "unapproved"] }, + }, + }, + }, + shippedOrders: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { $eq: ["$$order.status", "shipped"] }, + }, + }, + }, + delayedOrders: { + $size: { + $filter: { + input: "$orders", + as: "order", + cond: { $eq: ["$$order.status", "delayed"] }, + }, + }, + }, + + // Inventory Management Fields + availableProducts: { + $size: { + $filter: { + input: "$products", + as: "product", + cond: { $gt: ["$$product.stock", 0] }, // Fix here: provide two arguments to $gt + }, + }, + }, + outOfStockProducts: { + $size: { + $filter: { + input: "$products", + as: "product", + cond: { $eq: ["$$product.stock", 0] }, + }, + }, + }, + restockedLastWeek: { + $size: { + $filter: { + input: "$products", + as: "product", + cond: { + $gte: ["$$product.restockDate", new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)], + }, + }, + }, + }, + restockingProducts: { + $size: { + $filter: { + input: "$products", + as: "product", + cond: { $eq: ["$$product.restocking", true] }, + }, + }, + }, + rivalProducts: { + $size: { + $filter: { + input: "$products", + as: "product", + cond: { $eq: ["$$product.hasRival", true] }, + }, + }, + }, + }, + }, + { + $project: { + dateOfBirth: 0, + nationalCode: 0, + gender: 0, + shopNationalId: 0, + province: 0, + city: 0, + shebaNo: 0, + phoneNumber: 0, + workNumber: 0, + shopPostalCode: 0, + address: 0, + nationalCardImage: 0, + products: 0, + __v: 0, + updatedAt: 0, + }, + }, + ]); + } + + async getAllSellerForReport() { + return this.model.countDocuments(); + } +} + +function createSellerRepository(): SellerRepository { + return new SellerRepository(); +} + +export { SellerRepository, createSellerRepository }; diff --git a/src/modules/seller/seller.service.ts b/src/modules/seller/seller.service.ts new file mode 100644 index 0000000..642f51f --- /dev/null +++ b/src/modules/seller/seller.service.ts @@ -0,0 +1,960 @@ +import { randomBytes } from "crypto"; + +import { inject, injectable } from "inversify"; +import { FilterQuery, isValidObjectId, startSession } from "mongoose"; + +import { CompleteRespirationLegalDTO, CompleteRespirationRealDTO } from "./DTO/complete-register.dto"; +import { CreateDocumentTypeDTO } from "./DTO/createDocumentType"; +import { LegalSellerUpdateDto, RealSellerUpdateDto } from "./DTO/sellerUpdate.dto"; +import { BusinessTypeEnum } from "./enum/seller.enum"; +import { DocumentTypeRepo } from "./repository/documentType.repository"; +import { LegalSellerRepo } from "./repository/legalSeller.repository"; +import { SellerContractRepo } from "./repository/sellerContract.repository"; +import { SellerStatusRepo } from "./repository/sellerStatus.repository"; +import { SellerRepository } from "./seller.repository"; +import { PaginationDTO } from "../../common/dto/pagination.dto"; +import { + AddressMessage, + AuthMessage, + CommonMessage, + ContractMessage, + SellerMessage, + SetShipmentMessage, + ShopMessage, +} from "../../common/enums/message.enum"; +import { OrderItemsStatus } from "../../common/enums/order.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { contractTemplate } from "../../template/contract"; +import { ChatService } from "../chat/chat.service"; +import { OrderItemRepo } from "../order/order.repository"; +import { CommentRepository } from "../product/Repository/comment"; +import { ProductRepository } from "../product/Repository/product"; +import { ProductVariantRepository } from "../product/Repository/productVarinat"; +import { OwnerRef } from "../shop/models/Abstraction/IShop"; +import { ShopRepo } from "../shop/shop.repository"; +import { ShopService } from "../shop/shop.service"; +import { UploadSellerDocumentDTO } from "./DTO/update-sellerDocument.dto"; +import { UpdateShopShipmentDTO } from "./DTO/update-shop-shipment.dto"; +import { ILegalSeller } from "./models/Abstraction/ILegal-seller"; +import { IRealSeller } from "./models/Abstraction/IReal-seller"; +import { RealSellerRepo } from "./repository/realSeller.repository"; +import { SellerDocumentRepo } from "./repository/sellerDocument.repository"; +import { ContractType } from "./types/contract.types"; +import { ContractRepository } from "../admin/repository/contract"; +import { ISeller } from "./models/Abstraction/ISeller"; +import { WholesaleRequestRepo } from "./repository/wholesaleRequest.repositroy"; +import { StatusEnum } from "../../common/enums/status.enum"; +import { paginationUtils } from "../../utils/pagination.utils"; +import { UpdateSellerDocumentDto } from "../admin/DTO/updateSellerDocument.dto"; +import { NotificationService } from "../notification/notification.service"; +import { TicketService } from "../ticket/ticket.service"; + +@injectable() +class SellerService { + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + @inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository; + @inject(IOCTYPES.OrderItemRepo) orderItemRepo: OrderItemRepo; + @inject(IOCTYPES.ShopService) shopService: ShopService; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + @inject(IOCTYPES.WholesaleRequestRepo) wholesaleRequestRepo: WholesaleRequestRepo; + @inject(IOCTYPES.SellerStatusRepo) sellerStatusRepo: SellerStatusRepo; + @inject(IOCTYPES.RealSellerRepo) realSellerRepo: RealSellerRepo; + @inject(IOCTYPES.LegalSellerRepo) legalSellerRepo: LegalSellerRepo; + @inject(IOCTYPES.SellerDocumentRepo) sellerDocumentRepo: SellerDocumentRepo; + @inject(IOCTYPES.SellerContractRepo) sellerContractRepo: SellerContractRepo; + @inject(IOCTYPES.DocumentTypeRepo) documentTypeRepo: DocumentTypeRepo; + @inject(IOCTYPES.CommentRepository) commentRepo: CommentRepository; + @inject(IOCTYPES.ContractRepository) contractRepo: ContractRepository; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + @inject(IOCTYPES.TicketService) ticketService: TicketService; + @inject(IOCTYPES.ChatService) chatService: ChatService; + + //################################### + + // async getSellers(queryDto: PaginationDTO) { + // const { limit, skip } = paginationUtils(queryDto); + // const count = await this.sellerRepo.model.countDocuments(); + // const sellers = await this.sellerRepo.model.find().skip(skip).limit(limit).lean(); + // return { sellers, count }; + // } + + //############# + async getSellersWithDocumentsAndContracts(queryDto: PaginationDTO) { + const { limit, skip } = paginationUtils(queryDto); + const count = await this.sellerRepo.model.countDocuments(); + const sellers = await this.sellerRepo.model.aggregate([ + { + $lookup: { + from: "sellerdocuments", + localField: "_id", + foreignField: "seller", + as: "documents", + }, + }, + { + $lookup: { + from: "shops", + localField: "_id", + foreignField: "owner", + as: "shop", + }, + }, + { + $unwind: "$shop", + }, + { + $lookup: { + from: "sellercontracts", + localField: "_id", + foreignField: "seller", + as: "contracts", + }, + }, + { + $unwind: { + path: "$contracts", + preserveNullAndEmptyArrays: true, + }, + }, + { $skip: skip }, + { $limit: limit }, + ]); + return { sellers, count }; + } + + async getSellerById(sellerId: string) { + if (!isValidObjectId(sellerId)) throw new BadRequestError(CommonMessage.NotValidId); + const seller = await this.sellerRepo.model.findById(sellerId).lean(); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + return { + seller, + }; + } + + //################################### + + async approveSellerRegistration(sellerId: string) { + const session = await startSession(); + session.startTransaction(); + try { + if (!isValidObjectId(sellerId)) throw new BadRequestError(CommonMessage.NotValidId); + + const seller = await this.sellerRepo.model.findById(sellerId); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + + const sellerStatus = await this.sellerStatusRepo.model.findOne({ seller: sellerId }); + if (!sellerStatus) throw new BadRequestError(SellerMessage.StatusNotFound); + + if (sellerStatus.register && seller.accountStatus === StatusEnum.Approved) { + seller.accountStatus = StatusEnum.Pending; + sellerStatus.register = false; + await this.notificationService.notifyDeactive(sellerId, session); + } else { + sellerStatus.register = true; + seller.accountStatus = StatusEnum.Approved; + } + + await sellerStatus.save({ session }); + + await seller.save({ session }); + + await session.commitTransaction(); + + return { + message: CommonMessage.Updated, + status: sellerStatus, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //################################### + + async updateSellerDocumentStatus(sellerId: string, updateDto: UpdateSellerDocumentDto) { + if (!isValidObjectId(sellerId)) throw new BadRequestError(CommonMessage.NotValidId); + + const seller = await this.sellerRepo.model.findById(sellerId); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + + const document = await this.sellerDocumentRepo.model.findOneAndUpdate( + { _id: updateDto.docsId, seller: sellerId }, + { status: updateDto.status }, + ); + + if (!document) throw new BadRequestError(SellerMessage.SellerDocumentNotFound); + + return { + message: CommonMessage.Updated, + status: document.status, + }; + } + + //################################### + + async deleteSellerDocument(documentId: string, rejectReason: string) { + const document = await this.sellerDocumentRepo.model.findByIdAndDelete(documentId); + console.log({ document }); + if (!document) throw new BadRequestError(SellerMessage.SellerDocumentNotFound); + await this.notificationService.notifyRejectDocument(document.seller.toString(), rejectReason); + return { + message: CommonMessage.Deleted, + }; + } + + //################################### + + async approveSellerContract(sellerId: string) { + const session = await startSession(); + session.startTransaction(); + try { + if (!isValidObjectId(sellerId)) throw new BadRequestError(CommonMessage.NotValidId); + + const seller = await this.sellerRepo.model.findById(sellerId).session(session); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + + const sellerStatus = await this.sellerStatusRepo.model.findOne({ seller: sellerId }).session(session); + if (!sellerStatus) throw new BadRequestError(SellerMessage.StatusNotFound); + + sellerStatus.contract = true; + + await sellerStatus.save({ session }); + + this.notificationService.notifyApproveContract(sellerId, session); + + await session.commitTransaction(); + + return { + message: CommonMessage.Updated, + status: sellerStatus, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //################################### + + async approveSellerWholesale(requestId: string) { + const session = await startSession(); + session.startTransaction(); + try { + if (!isValidObjectId(requestId)) throw new BadRequestError(CommonMessage.NotValidId); + + const wholesaleRequest = await this.wholesaleRequestRepo.model.findById(requestId).session(session); + if (!wholesaleRequest) throw new BadRequestError(SellerMessage.WholesaleRequestNotFound); + + const seller = await this.sellerRepo.model.findById(wholesaleRequest.seller).session(session); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + + wholesaleRequest.status = StatusEnum.Approved; + + seller.isWholesaler = true; + + await this.notificationService.notifyWholesaleRequestApproved(seller._id.toString(), session); + + await seller.save({ session }); + await wholesaleRequest.save({ session }); + + await session.commitTransaction(); + return { + message: SellerMessage.WholesaleRequestApproved, + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } + } + + async getWholesaleRequests(queryDto: PaginationDTO) { + const { limit, skip } = paginationUtils(queryDto); + const count = await this.wholesaleRequestRepo.model.countDocuments(); + const requests = await this.wholesaleRequestRepo.model + .find() + .limit(limit) + .skip(skip) + .populate([{ path: "seller" }, { path: "shop" }]); + + return { + requests, + count, + }; + } + //################################### + async getSellerPanelInfo(sellerId: string) { + const { shop } = await this.shopService.getShopInfo(sellerId, OwnerRef.SELLER); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + return { + shopInfo: shop, + productCount: await this.getProductCounts(shop._id.toString()), + logistic: await this.logistic(shop._id.toString()), + orderStats: await this.orderStats(shop._id.toString()), + salesStats: await this.salesStats(shop._id.toString()), + shopRating: await this.shopRating(shop._id.toString()), + }; + } + //#################################### + async updateShopShipper(sellerId: string, updateDto: UpdateShopShipmentDTO, type: "activate" | "deactivate") { + const { shipmentId } = updateDto; + + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + if (type === "activate") { + if (shop.shipmentMethod.includes(shipmentId)) throw new BadRequestError(SetShipmentMessage.ShipmentMethodAlreadyActive); + + shop.shipmentMethod.push(shipmentId); + } else { + if (!shop.shipmentMethod.includes(shipmentId)) throw new BadRequestError(SetShipmentMessage.ShipmentMethodNotActive); + + shop.shipmentMethod = shop.shipmentMethod.filter((methodId) => methodId !== shipmentId); + } + + await shop.save(); + + return { + message: CommonMessage.Updated, + }; + } + + async changeShopChatStatus(sellerId: string) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + shop.isChatActive = !shop.isChatActive; + await shop.save(); + + return { + message: CommonMessage.Updated, + }; + } + + //#################################### + + async getItemsCount(sellerId: string) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const notificationCount = await this.notificationService.getUnreadNotificationCount(sellerId); + const ticketCount = await this.ticketService.getUnreadTickerCount(sellerId); + const chatCount = await this.chatService.getUnreadChatCount(sellerId); + const orderCount = await this.orderItemRepo.model.countDocuments({ shop: shop._id, status: OrderItemsStatus.Processing }); + return { ticketCount, notificationCount, chatCount, orderCount }; + } + + //#################################### + async createDocumentType(createDto: CreateDocumentTypeDTO) { + const existDocumentType = await this.documentTypeRepo.model.exists({ title: createDto.title }); + if (existDocumentType) throw new BadRequestError(SellerMessage.DocumentTypeDuplicate); + const documentType = await this.documentTypeRepo.model.create(createDto); + return { + message: CommonMessage.Created, + documentType, + }; + } + //############################################################## + async getContract(seller: ISeller) { + let contract; + contract = await this.sellerContractRepo.model.findOne({ seller: seller._id }); + if (contract) { + return { + contract, + }; + } + const baseContract = await this.contractRepo.model.findOne(); + if (!baseContract) throw new BadRequestError(ContractMessage.ContractNotFound); + + let national_code; + let id_number; + + if (seller.businessType === BusinessTypeEnum.REAL) { + const realSeller = await this.realSellerRepo.model.findOne({ seller: seller._id }); + if (!realSeller) throw new BadRequestError(SellerMessage.SellerNotFound); + national_code = realSeller.nationalCode; + id_number = realSeller.nationalCode; + } else { + const legalSeller = await this.legalSellerRepo.model.findOne({ seller: seller._id }); + if (!legalSeller) throw new BadRequestError(SellerMessage.SellerNotFound); + national_code = legalSeller.companyNationalId; + id_number = legalSeller.companyNationalId; + } + + const { shop } = await this.shopService.getShopInfo(seller._id.toString(), OwnerRef.SELLER); + if (!shop.address) throw new BadRequestError(AddressMessage.SellerAddressNotFound); + + const contractContent = this.renderContractTemplate({ + content: baseContract.content, + seller_name: seller.fullName, + national_code, + address: shop.address.address, + postal_code: shop.address.postalCode, + date: new Date().toLocaleDateString("fa-IR"), + id_number, + email: seller.email, + phone_number: seller.phoneNumber, + }); + + const contractNumber = `SKC-${randomBytes(5).toString("hex")}-${Date.now().toString().slice(-5)}`; + + contract = await this.sellerContractRepo.model.create({ + seller: seller._id.toString(), + contractNumber, + content: contractContent, + }); + + return { + contract, + }; + } + //############################################################## + + async signContract(sellerId: string, contractId: string) { + if (!isValidObjectId(contractId)) throw new BadRequestError(CommonMessage.NotValidId); + const contract = await this.sellerContractRepo.model.findOne({ _id: contractId, seller: sellerId }); + if (!contract) throw new BadRequestError(ContractMessage.ContractNotFound); + if (contract.signed) throw new BadRequestError(ContractMessage.ContractAlreadySigned); + + contract.singedAt = new Date().toLocaleDateString("fa-IR"); + contract.signed = true; + await contract.save(); + return { + contract, + }; + } + //############################################################## + + async getDocumentTypes() { + const documentTypes = await this.documentTypeRepo.model.find(); + return { documentTypes }; + } + //############################################################## + + async uploadSellerDocument(sellerId: string, uploadDto: UploadSellerDocumentDTO) { + const updatedSellerDocument = await this.sellerDocumentRepo.model.findOneAndUpdate( + { seller: sellerId, type: uploadDto.documentTypeId }, + { seller: sellerId, type: uploadDto.documentTypeId, docsUrl: uploadDto.docsUrl }, + { new: true, upsert: true }, + ); + + return { + message: CommonMessage.Updated, + sellerDocument: updatedSellerDocument, + }; + } + //############################################################## + + async getSellerDocument(sellerId: string) { + const sellerDocument = await this.sellerDocumentRepo.model.find({ seller: sellerId }).populate("type"); + + return { sellerDocument }; + } + //############################################################## + + async getSingleSellerDocument(sellerId: string, documentId: string) { + const sellerDocument = await this.sellerDocumentRepo.model.findOne({ seller: sellerId, _id: documentId }).populate("type"); + if (!sellerDocument) throw new BadRequestError(SellerMessage.SellerDocumentNotFound); + + return { + sellerDocument, + }; + } + //############# + async updateSellerInfo(type: BusinessTypeEnum, updateDto: RealSellerUpdateDto | LegalSellerUpdateDto, sellerId: string) { + const session = await startSession(); + session.startTransaction(); + + try { + if (type === BusinessTypeEnum.REAL) { + const { nationalCode, cardNumber, shebaNumber, ...sellerInfoData } = updateDto as RealSellerUpdateDto; + //check for duplicate + await this.checkRealSellerDuplicate(sellerId, nationalCode, cardNumber, shebaNumber, true); + await this.sellerRepo.model.findByIdAndUpdate(sellerId, { ...sellerInfoData }, { new: true, session }); + await this.realSellerRepo.model.findOneAndUpdate( + { seller: sellerId }, + { nationalCode, cardNumber, shebaNumber }, + { new: true, session }, + ); + + await session.commitTransaction(); + + return { + message: CommonMessage.Updated, + }; + } else { + const { IBan, companyName, companyType, companyNationalId, companyEconomicNumber, ...sellerInfo } = + updateDto as LegalSellerUpdateDto; + //check for duplicate + await this.checkCompanyDuplicate(sellerId, companyName, companyNationalId, companyEconomicNumber, IBan, true); + await this.sellerRepo.model.findByIdAndUpdate(sellerId, { ...sellerInfo }, { new: true, session }); + await this.legalSellerRepo.model.findOneAndUpdate( + { seller: sellerId }, + { companyName, companyNationalId, companyEconomicNumber, IBan, companyType }, + { new: true, session }, + ); + + await session.commitTransaction(); + return { + message: CommonMessage.Updated, + }; + } + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################### + + async completeRegistrationReal(sellerId: string, completeRealDto: CompleteRespirationRealDTO) { + const session = await startSession(); + session.startTransaction(); + + try { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const { nationalCode, cardNumber, shebaNumber, ...sellerInfo } = completeRealDto; + + const existEmail = await this.sellerRepo.model.exists({ _id: { $ne: sellerId }, email: sellerInfo.email }).session(session); + if (existEmail) throw new BadRequestError(AuthMessage.EmailExists); + + const seller = await this.sellerRepo.model.findByIdAndUpdate( + sellerId, + { ...sellerInfo, businessType: sellerInfo.business_type, isRegisterCompleted: true }, + { new: true, session }, + ); + + const existShopName = await this.shopRepo.model.find({ shopName: completeRealDto.companyName }); + if (existShopName.length) throw new BadRequestError(ShopMessage.ShopNameExist); + + shop.shopName = completeRealDto.companyName; + await shop.save(); + + await this.checkRealSellerDuplicate(sellerId, nationalCode, cardNumber, shebaNumber); + + const realSeller = await this.realSellerRepo.model.create([{ seller: sellerId, nationalCode, cardNumber, shebaNumber }], { session }); + + await this.sellerStatusRepo.model.create([{ seller: sellerId }], { session }); + + await session.commitTransaction(); + return { + message: CommonMessage.Updated, + seller, + realSeller: realSeller[0], + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################### + async completeRegistrationLegal(sellerId: string, completeLegalDto: CompleteRespirationLegalDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const { IBan, companyName, companyType, companyNationalId, companyEconomicNumber, ...sellerInfo } = completeLegalDto; + + const existEmail = await this.sellerRepo.model.exists({ _id: { $ne: sellerId }, email: sellerInfo.email }); + if (existEmail) throw new BadRequestError(AuthMessage.EmailExists); + + const seller = await this.sellerRepo.model.findByIdAndUpdate( + sellerId, + { ...sellerInfo, businessType: sellerInfo.business_type, isRegisterCompleted: true }, + { new: true, session }, + ); + + const existShopName = await this.shopRepo.model.find({ shopName: completeLegalDto.companyName }); + if (existShopName.length) throw new BadRequestError(ShopMessage.ShopNameExist); + + shop.shopName = completeLegalDto.companyName; + await shop.save(); + + await this.checkCompanyDuplicate(companyName, companyNationalId, companyEconomicNumber, IBan); + + const legalSeller = await this.legalSellerRepo.model.create( + [{ seller: sellerId, IBan, companyName, companyType, companyNationalId, companyEconomicNumber }], + { session }, + ); + + await this.sellerStatusRepo.model.create([{ seller: sellerId }], { session }); + + await session.commitTransaction(); + return { + message: CommonMessage.Updated, + seller, + legal: legalSeller[0], + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################### + async getSellerProfileInfo(sellerId: string) { + const seller = await this.sellerRepo.model.findById(sellerId); + if (!seller) throw new BadRequestError(SellerMessage.SellerNotFound); + + if (seller.businessType === BusinessTypeEnum.REAL) { + const realSeller = await this.realSellerRepo.model.findOne({ seller: sellerId }); + return { + baseSeller: seller, + realSeller, + }; + } + + const legalSeller = await this.legalSellerRepo.model.findOne({ seller: sellerId }); + + return { + baseSeller: seller, + legalSeller, + }; + } + + //####################### + async requestWholesale(sellerId: string) { + const shop = await this.shopRepo.model.findOne({ owner: sellerId, ownerRef: OwnerRef.SELLER }); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const existRequest = await this.wholesaleRequestRepo.model.exists({ seller: sellerId, shop: shop._id }); + if (existRequest) throw new BadRequestError(SellerMessage.WholesaleRequestPending); + + const wholesaleRequest = await this.wholesaleRequestRepo.model.create({ seller: sellerId, shop: shop._id }); + + return { + message: SellerMessage.WholesaleRequestCreated, + request: wholesaleRequest, + }; + } + + //####################### + async getSellerStatus(sellerId: string) { + let isSellerActive = false; + const status = await this.sellerStatusRepo.model.findOne({ seller: sellerId }).lean(); + // if (status?.banned) throw new BadRequestError("اکانت شما از دسترس خارج شده است. با پشتیبانی تماس بگیرید.") + if (status?.contract && status?.register && status?.document && status?.learning) { + isSellerActive = true; + } + // if (!status) throw new BadRequestError(SellerMessage.StatusNotFound); + return { + status, + isSellerActive, + }; + } + + //####################### + async getAllSellerForReport() { + return await this.sellerRepo.getAllSellerForReport(); + } + + //####################### + async getWholesaleRequestCount() { + return await this.wholesaleRequestRepo.getWholesaleRequestCount(); + } + + /** helper methods */ + private async getProductCounts(shopId: string) { + return this.productRepo.model.countDocuments({ shop: shopId }); + } + + private async logistic(shopId: string) { + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + const shopCategories = await this.productRepo.model.distinct("category", { shop: shopId }); + const rivalProductsCount = await this.productRepo.model.countDocuments({ + shop: { $ne: shopId }, + category: { $in: shopCategories }, + }); + + const returnedLastWeekCount = await this.orderItemRepo.model.aggregate([ + { + $match: { + shop: shopId, + "shipmentItems.returned_quantity": { $gt: 0 }, + createdAt: { $gte: oneWeekAgo }, + }, + }, + { + $group: { _id: null, totalReturns: { $sum: "$shipmentItems.returned_quantity" } }, + }, + ]); + const returnedLastWeek = returnedLastWeekCount[0]?.totalReturns || 0; + + return { + availableProducts: await this.productVariantRepo.model.countDocuments({ stock: { $gte: 5 }, shop: shopId }), + outOfStockProducts: await this.productVariantRepo.model.countDocuments({ stock: 0, shop: shopId }), + restockingProducts: await this.productVariantRepo.model.countDocuments({ stock: { $lte: 5, $gt: 0 }, shop: shopId }), + returnedLastWeek, + rivalProducts: rivalProductsCount, + }; + } + + private async orderStats(shopId: string) { + const orderCount = await this.orderItemRepo.model.countDocuments({ shop: shopId }); + + const completedOrdersCount = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Delivered, + }); + + const startOfWeek = new Date(); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + const ordersThisWeek = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + createdAt: { $gte: startOfWeek }, + }); + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const ordersToday = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + createdAt: { $gte: startOfToday }, + }); + + const unapprovedOrders = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Processing, + }); + + const shippedOrders = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Shipped, + }); + + const delayedOrders = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + postingDate: { $lt: new Date() }, + status: { $ne: OrderItemsStatus.Delivered }, + }); + + return { + orderCount, + completedOrdersCount, + ordersThisWeek, + ordersToday, + unapprovedOrders, + shippedOrders, + delayedOrders, + }; + } + + private async salesStats(shopId: string) { + const startOfWeek = new Date(); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + const startOfLastWeek = new Date(startOfWeek); + startOfLastWeek.setDate(startOfWeek.getDate() - 7); + const startOfMonth = new Date(); + startOfMonth.setDate(1); + + const totalSale = await this.orderItemRepo.model.aggregate([ + { $match: { shop: shopId, status: OrderItemsStatus.Delivered } }, + { $group: { _id: null, total: { $sum: "$totalSellingPrice" } } }, + ]); + const totalSaleAmount = totalSale[0]?.total || 0; + + const weeklySale = await this.orderItemRepo.model.aggregate([ + { + $match: { + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { $gte: startOfWeek }, + }, + }, + { $group: { _id: null, total: { $sum: "$totalSellingPrice" } } }, + ]); + const weeklySaleAmount = weeklySale[0]?.total || 0; + + const previousWeekSale = await this.orderItemRepo.model.aggregate([ + { + $match: { + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { $gte: startOfLastWeek, $lt: startOfWeek }, + }, + }, + { $group: { _id: null, total: { $sum: "$totalSellingPrice" } } }, + ]); + const previousWeekSaleAmount = previousWeekSale[0]?.total || 0; + + const monthlySale = await this.orderItemRepo.model.aggregate([ + { + $match: { + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { $gte: startOfMonth }, + }, + }, + { $group: { _id: null, total: { $sum: "$totalSellingPrice" } } }, + ]); + const monthlySaleAmount = monthlySale[0]?.total || 0; + + const salesCountWeekly = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { $gte: startOfWeek }, + }); + + const salesCountPreviousWeek = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { $gte: startOfLastWeek, $lt: startOfWeek }, + }); + + const salesCountPreviousMonth = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Delivered, + createdAt: { + $gte: new Date(new Date().setDate(0)), + $lt: startOfMonth, + }, + }); + + return { + totalSale: totalSaleAmount, + weeklySale: weeklySaleAmount, + previousWeekSale: previousWeekSaleAmount, + monthlySale: monthlySaleAmount, + salesCountWeekly, + salesCountPreviousWeek, + salesCountPreviousMonth, + }; + } + + private async shopRating(shopId: string) { + const shop = await this.shopRepo.model.findById(shopId); + if (!shop) throw new BadRequestError(ShopMessage.ShopNotFound); + + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const ratingData = await this.commentRepo.model.aggregate([ + { + $match: { + product: { $in: await this.productRepo.model.distinct("_id", { shop: shopId }) }, + rate: { $exists: true }, + }, + }, + { + $group: { + _id: null, + totalRate: { $sum: "$rate" }, + totalCount: { $sum: 1 }, + }, + }, + ]); + + const totalRate = ratingData[0]?.totalRate || 0; + const totalCount = ratingData[0]?.totalCount || 0; + + const onTimeShipments = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.Delivered, + postingDate: { $exists: true, $ne: null }, + createdAt: { $gte: oneMonthAgo }, + }); + + const returnsCount = await this.orderItemRepo.model.aggregate([ + { + $match: { + shop: shopId, + "shipmentItems.returned_quantity": { $gt: 0 }, + createdAt: { $gte: oneMonthAgo }, + }, + }, + { + $group: { _id: null, totalReturns: { $sum: "$shipmentItems.returned_quantity" } }, + }, + ]); + const totalReturns = returnsCount[0]?.totalReturns || 0; + + const cancellationsCount = await this.orderItemRepo.model.countDocuments({ + shop: shopId, + status: OrderItemsStatus.cancelled_shop, + createdAt: { $gte: oneMonthAgo }, + }); + + shop!.rate.cancel = cancellationsCount; + shop!.rate.onTimeShip = onTimeShipments; + shop!.rate.returns = totalReturns; + shop!.rate.totalCount = totalCount; + shop!.rate.totalRate = totalRate; + + await shop!.save(); + + return { + totalRate, + totalCount, + onTimeShip: onTimeShipments, + returns: totalReturns, + cancel: cancellationsCount, + }; + } + + private async checkCompanyDuplicate( + sellerId: string, + companyName?: string, + companyNationalId?: string, + companyEconomicNumber?: string, + IBan?: string, + update: boolean = false, + ) { + const query: FilterQuery = { + $or: [{ companyName }, { companyNationalId }, { companyEconomicNumber }, { IBan }], + }; + + if (update) query.seller = { $ne: sellerId }; + + const existCompany = await this.legalSellerRepo.model.findOne(query); + if (existCompany) throw new BadRequestError(SellerMessage.CompanyDuplicate); + + return true; + } + + private async checkRealSellerDuplicate( + sellerId: string, + nationalCode?: string, + cardNumber?: string, + shebaNumber?: string, + update: boolean = false, + ) { + const query: FilterQuery = { + $or: [{ nationalCode }, { cardNumber }, { shebaNumber }], + }; + + if (update) query.seller = { $ne: sellerId }; + + const existRealSeller = await this.realSellerRepo.model.findOne(query); + + if (existRealSeller) throw new BadRequestError(SellerMessage.RealSellerDuplicate); + + return true; + } + + private renderContractTemplate(data: ContractType): string { + return contractTemplate(data); + } +} + +export { SellerService }; diff --git a/src/modules/seller/types/contract.types.ts b/src/modules/seller/types/contract.types.ts new file mode 100644 index 0000000..533ea09 --- /dev/null +++ b/src/modules/seller/types/contract.types.ts @@ -0,0 +1,11 @@ +export type ContractType = { + date: string; + seller_name: string; + id_number: string; + national_code: string; + address: string; + postal_code: string; + phone_number: string; + email: string; + content: string; +}; diff --git a/src/modules/shipment/DTO/calculateShipCost.dto.ts b/src/modules/shipment/DTO/calculateShipCost.dto.ts new file mode 100644 index 0000000..4967380 --- /dev/null +++ b/src/modules/shipment/DTO/calculateShipCost.dto.ts @@ -0,0 +1,40 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ShopModel } from "../../shop/models/shop.model"; +import { ShipmentModel } from "../models/shipment.model"; + +class ShipperSelectionDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(ShopModel) + @ApiProperty({ type: "string", description: "shop ID", example: "60d725ebcfc0f308e6f5c92b" }) + shopId: string; + + @Expose() + @IsNotEmpty() + @IsNumber() + @IsValidId(ShipmentModel) + @ApiProperty({ type: "number", description: "Shipment ID for the selected seller", example: 102 }) + shipmentId: number; +} + +export class SaveSellerShipmentInfoDTO { + @ApiProperty({ + type: "Array", + description: "Array of seller IDs and their selected shipment provider", + example: [ + { shopId: "60d725ebcfc0f308e6f5c92b", shipmentId: 102 }, + { shopId: "60d726abcfc0f308e6f5c92d", shipmentId: 104 }, + ], + }) + @Expose() + @IsArray() + @ArrayMinSize(1, { message: "حداقل باید یک روش ارسال انتخاب شود" }) + @Type(() => ShipperSelectionDTO) + @ValidateNested({ each: true }) + shipmentsInfo: ShipperSelectionDTO[]; +} diff --git a/src/modules/shipment/DTO/createShipmentProviders.dto.ts b/src/modules/shipment/DTO/createShipmentProviders.dto.ts new file mode 100644 index 0000000..eed0452 --- /dev/null +++ b/src/modules/shipment/DTO/createShipmentProviders.dto.ts @@ -0,0 +1,74 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +// DTO for Shipment Cost +class WeightRangeDTO { + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Minimum weight in the range in kg", example: 0 }) + min: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Maximum weight in the range in kg", example: 10 }) + max: number; +} +class ShipmentCostDTO { + @Expose() + @IsNotEmpty() + @Type(() => WeightRangeDTO) + weightRange: WeightRangeDTO; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Cost of shipping for the given weight range for first items", example: 40000 }) + cost_first: number; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Cost of shipping for the given weight range for other items ", example: 40000 }) + cost_rest: number; +} + +// Main DTO for Creating Shipment Providers +export class CreateShipProvidersDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "The name of the provider", example: "چاپار" }) + name: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "Description of provider", example: "ارائه بهترین راه حل های جهانی لجستیک چاپار" }) + description: string; + + @Expose() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => ShipmentCostDTO) + @ApiProperty({ + type: "Array", + description: "Costs for various weight ranges", + example: [ + { weightRange: { min: 0, max: 1 }, cost_first: 40000, cost_rest: 2000 }, + { weightRange: { min: 1, max: 5 }, cost_first: 80000, cost_rest: 4000 }, + { weightRange: { min: 5, max: 10 }, cost_first: 100000, cost_rest: 5000 }, + ], + }) + costs: ShipmentCostDTO[]; + + @Expose() + @IsNotEmpty() + @IsNumber() + @ApiProperty({ type: "number", description: "Delivery time in days", example: 3 }) + deliveryTime: number; +} diff --git a/src/modules/shipment/DTO/shipment.dto.ts b/src/modules/shipment/DTO/shipment.dto.ts new file mode 100644 index 0000000..213ea0e --- /dev/null +++ b/src/modules/shipment/DTO/shipment.dto.ts @@ -0,0 +1,92 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { CartItemsDTO } from "../../cart/DTO/cart.dto"; + +class ShipperItemsDTO { + @Expose() + product: number; + + @Expose() + variant: string; + + @Expose() + quantity: number; + + @Expose() + selling_price: number; + + @Expose() + retail_price: number; + + @Expose() + discount_percent: number; + + @Expose() + shippingCost: number; + + @Expose() + isFreeShip: boolean; +} + +class ShipperInfoDTO { + @Expose() + shipperId: number; + + @Expose() + shipperName: string; + + @Expose() + shippingDaysRange: string[]; + + @Expose() + totalShippingCost: number; + + @Expose() + @Type(() => ShipperItemsDTO) + items: ShipperItemsDTO[]; +} + +//*********************************** +//*********************************** + +export class ShippingOptionDTO { + @Expose() + shopId: string; + + @Expose() + shopName: string; + + @Expose() + @Type(() => CartItemsDTO) + items: CartItemsDTO[]; + + @Expose() + @Type(() => ShipperInfoDTO) + shippers: ShipperInfoDTO[]; + + public static transformShipment(data: Record): ShippingOptionDTO { + const shipmentDTO = plainToInstance(ShippingOptionDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return shipmentDTO; + } +} + +export class ShipmentMethodDTO { + @Expose() + _id: number; + + @Expose() + name: string; + + @Expose() + description: string; + + @Expose() + deliveryTime: number; + + @Expose() + deliveryType: string; +} diff --git a/src/modules/shipment/DTO/updateShipment.dto.ts b/src/modules/shipment/DTO/updateShipment.dto.ts new file mode 100644 index 0000000..5132b11 --- /dev/null +++ b/src/modules/shipment/DTO/updateShipment.dto.ts @@ -0,0 +1,16 @@ +import { PartialType } from "@nestjs/mapped-types"; +import { Expose } from "class-transformer"; +import { IsEnum, IsNotEmpty, IsOptional } from "class-validator"; + +import { CreateShipProvidersDTO } from "./createShipmentProviders.dto"; +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { DeliveryType } from "../../../common/enums/shipment.enum"; + +export class UpdateShipmentProviderDTO extends PartialType(CreateShipProvidersDTO) { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsEnum(DeliveryType) + @ApiProperty({ type: "string" }) + deliveryType?: DeliveryType; +} diff --git a/src/modules/shipment/models/Abstraction/IShipmentProviders.ts b/src/modules/shipment/models/Abstraction/IShipmentProviders.ts new file mode 100644 index 0000000..d622402 --- /dev/null +++ b/src/modules/shipment/models/Abstraction/IShipmentProviders.ts @@ -0,0 +1,16 @@ +import { DeliveryType } from "../../../../common/enums/shipment.enum"; + +export interface IShipmentProviders { + _id: number; + name: string; + description: string; + costs: IShipmentCost[]; + deliveryType: DeliveryType; + deliveryTime: number; + deleted: boolean; +} +export interface IShipmentCost { + weightRange: { min: number; max: number }; // weight range in kg + cost_first: number; + cost_rest: number; +} diff --git a/src/modules/shipment/models/shipment.model.ts b/src/modules/shipment/models/shipment.model.ts new file mode 100644 index 0000000..91c6430 --- /dev/null +++ b/src/modules/shipment/models/shipment.model.ts @@ -0,0 +1,39 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IShipmentCost, IShipmentProviders } from "./Abstraction/IShipmentProviders"; +import { DeliveryType } from "../../../common/enums/shipment.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const ShipmentCostSchema = new Schema( + { + weightRange: { + min: { type: Number, required: true }, + max: { type: Number, required: true }, + }, + cost_first: { type: Number, required: true }, + cost_rest: { type: Number, required: true }, + }, + { _id: false }, +); + +const ShipmentSchema = new Schema( + { + _id: Number, + name: { type: String, unique: true, required: true }, + description: String, + // availableShippingDays: { type: [String], require: true }, + costs: [ShipmentCostSchema], + deliveryType: { type: String, enum: DeliveryType, required: true }, + deliveryTime: { type: Number, required: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false }, _id: false, id: false }, +); + +ShipmentSchema.plugin(AutoIncrement, { id: "shipment_id", inc_field: "_id", start_seq: 100 }); + +const ShipmentModel = model("Shipment", ShipmentSchema); + +export { ShipmentModel, IShipmentProviders }; diff --git a/src/modules/shipment/shipment.controller.ts b/src/modules/shipment/shipment.controller.ts new file mode 100644 index 0000000..0c61219 --- /dev/null +++ b/src/modules/shipment/shipment.controller.ts @@ -0,0 +1,58 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody } from "inversify-express-utils"; + +import { ShipmentService } from "./shipment.service"; +import { HttpStatus } from "../../common"; +import { SaveSellerShipmentInfoDTO } from "./DTO/calculateShipCost.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/shipment") +@ApiTags("Shipment") +class ShipmentController extends BaseController { + @inject(IOCTYPES.ShipmentService) shipmentService: ShipmentService; + // + + @ApiOperation("get list of all shipment provider") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @httpGet("") + public async getShipmentProviders(@queryParam("limit") limit: string, @queryParam("page") page: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + }; + const { count, shipper } = await this.shipmentService.getAllProviders(queries); + const { pager } = this.paginate(count); + return this.response({ pager, shipper }); + } + + @ApiOperation("Calculate shipment cost for checkout") + @ApiResponse("Successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/shipping-cost", Guard.authUser()) + public async calculateShipCost(@request() req: Request) { + const user = req.user as IUser; + const data = await this.shipmentService.calculateAllShipmentOptions(user._id.toString()); + return this.response(data); + } + + @ApiOperation("save shipment info for checkout") + @ApiResponse("Successful", HttpStatus.Ok) + @ApiModel(SaveSellerShipmentInfoDTO) + @ApiAuth() + @httpPost("/save", Guard.authUser(), ValidationMiddleware.validateInput(SaveSellerShipmentInfoDTO)) + public async saveShipment(@requestBody() saveShippingInfo: SaveSellerShipmentInfoDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.shipmentService.selectShipmentProvider(saveShippingInfo, user._id.toString()); + return this.response(data); + } +} + +export { ShipmentController }; diff --git a/src/modules/shipment/shipment.repository.ts b/src/modules/shipment/shipment.repository.ts new file mode 100644 index 0000000..e3877b7 --- /dev/null +++ b/src/modules/shipment/shipment.repository.ts @@ -0,0 +1,25 @@ +import { IShipmentProviders } from "./models/Abstraction/IShipmentProviders"; +import { ShipmentModel } from "./models/shipment.model"; +import { BaseRepository } from "../../common/base/repository"; + +class ShipmentRepository extends BaseRepository { + constructor() { + super(ShipmentModel); + } + async findAllShipper(queries: { limit: number; page: number }) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + + const count = await this.model.countDocuments({ deleted: false }); + const shipper = await this.model.find({ deleted: false }).skip(skip).limit(limit); + + return { shipper, count }; + } +} + +function createShipmentRepository(): ShipmentRepository { + return new ShipmentRepository(); +} + +export { createShipmentRepository, ShipmentRepository }; diff --git a/src/modules/shipment/shipment.service.ts b/src/modules/shipment/shipment.service.ts new file mode 100644 index 0000000..9bbfe70 --- /dev/null +++ b/src/modules/shipment/shipment.service.ts @@ -0,0 +1,352 @@ +import { inject, injectable } from "inversify"; +import { Types } from "mongoose"; + +import { SaveSellerShipmentInfoDTO } from "./DTO/calculateShipCost.dto"; +import { CreateShipProvidersDTO } from "./DTO/createShipmentProviders.dto"; +import { ShippingOptionDTO } from "./DTO/shipment.dto"; +import { IShipmentProviders } from "./models/shipment.model"; +import { ShipmentRepository } from "./shipment.repository"; +import { Items } from "./types/items"; +import { CartMessage, CommonMessage, SetShipmentMessage } from "../../common/enums/message.enum"; +import { DeliveryType } from "../../common/enums/shipment.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { CartRepository, CartShipItemRepo } from "../cart/cart.repository"; +import { ICart } from "../cart/models/Abstraction/ICart"; +import { ProductVariantRepository } from "../product/Repository/productVarinat"; +import { SellerRepository } from "../seller/seller.repository"; +import { ShopRepo } from "../shop/shop.repository"; +import { UpdateShipmentProviderDTO } from "./DTO/updateShipment.dto"; + +@injectable() +class ShipmentService { + @inject(IOCTYPES.ShipmentRepository) shipmentRepo: ShipmentRepository; + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + @inject(IOCTYPES.CartRepository) cartRepo: CartRepository; + @inject(IOCTYPES.ProductVariantRepository) productVariantRepo: ProductVariantRepository; + @inject(IOCTYPES.CartShipItemRepo) cartShipItemRepo: CartShipItemRepo; + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + + async getAllProviders(queries: { limit: number; page: number }) { + const { count, shipper } = await this.shipmentRepo.findAllShipper(queries); + return { count, shipper }; + } + + //################################################################### + //################################################################### + async createShipmentProviderS(createDto: CreateShipProvidersDTO) { + const existShipper = await this.shipmentRepo.model.exists({ name: createDto.name }); + if (existShipper) throw new BadRequestError(CommonMessage.DuplicateName); + + const deliveryType = this.getDeliveryType(createDto.deliveryTime); + const newProvider = await this.shipmentRepo.model.create({ deliveryType, ...createDto }); + return { + message: CommonMessage.Created, + newProvider, + }; + } + + //################################################################### + async updateShipmentProvider(shipmentId: number, updateDto: UpdateShipmentProviderDTO) { + const existShipper = await this.shipmentRepo.model.exists({ name: updateDto.name, _id: { $ne: shipmentId } }); + if (existShipper) throw new BadRequestError(CommonMessage.DuplicateName); + + if (updateDto.deliveryTime) { + updateDto.deliveryType = this.getDeliveryType(updateDto.deliveryTime); + } + + const updatedProvider = await this.shipmentRepo.model.findByIdAndUpdate(shipmentId, updateDto, { new: true }); + if (!updatedProvider) throw new BadRequestError(CommonMessage.NotFoundById); + + return { + message: CommonMessage.Updated, + updatedProvider, + }; + } + //################################################################### + + async deleteShipper(shipperId: number) { + const shipper = await this.shipmentRepo.model.findByIdAndUpdate(shipperId, { deleted: true }, { new: true }); + if (!shipper) throw new BadRequestError(CommonMessage.NotFoundById); + return { + message: CommonMessage.Deleted, + shipper, + }; + } + + //################################################################### + //################################################################### + async calculateAllShipmentOptions(userId: string) { + // get the cart for the user + const cart = await this.cartRepo.getCartWithPopulate(userId); + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + console.log({ cart }); + const shipping = []; + const cartItems = cart.items as unknown as Items[]; + + // Group items by shop + const itemsByShop = cartItems.reduce>((acc, item) => { + const shopId = item.variant.shop._id.toString(); + if (!acc[shopId]) { + acc[shopId] = []; + } + acc[shopId].push({ + product: item.product, + variant: item.variant, + quantity: item.quantity, + }); + return acc; + }, {}); + console.log({ itemsByShop }); + // iterate through each shops items + for (const [shopId, items] of Object.entries(itemsByShop)) { + const shop = await this.shopRepo.model.findById(shopId).lean(); + console.log(shop); + if (!shop) { + continue; + } + + const shopShippers = []; + + // for each shop, check all available shippers + for (const shipperId of shop.shipmentMethod) { + const shipmentProvider = await this.shipmentRepo.model.findById(shipperId).lean(); + console.log({ shipmentProvider }); + if (!shipmentProvider) { + console.log("Shipment provider not found for shipper ID:", shipperId); + continue; + } + + // group items by product and variant + const itemsByProductVariant = items.reduce>((acc, item) => { + const key = `${item.product}-${item.variant._id}`; + if (!acc[key]) { + acc[key] = { product: item.product, variant: item.variant, quantity: 0, weight: 0 }; + } + acc[key].quantity += item.quantity; + acc[key].weight += item.variant.dimensions.package_weight * item.quantity; + return acc; + }, {}); + + let totalShippingCost = 0; + const shipperItems = []; + console.log({ itemsByProductVariant }); + // calculate shipping cost and gather item details for each shipper + for (const { product, variant, quantity, weight } of Object.values(itemsByProductVariant)) { + const shippingCostForItem = await this.calculateShippingCost(shipmentProvider, weight, quantity); + totalShippingCost += shippingCostForItem; + + shipperItems.push({ + product: product._id, + variant: variant._id, + quantity, + selling_price: variant.price.selling_price, + retail_price: variant.price.retailPrice, + discount_percent: variant.price.discount_percent, + shippingCost: variant.isFreeShip ? 0 : shippingCostForItem, + isFreeShip: variant.isFreeShip, + }); + } + + // push shipper details for this seller + shopShippers.push({ + shipperId: shipmentProvider._id, + shipperName: shipmentProvider.name, + shippingDaysRange: this.calculateShippingDays(shipmentProvider.deliveryType, shipmentProvider.deliveryTime), + totalShippingCost, + items: shipperItems, + }); + } + + // push seller info and their shippers + shipping.push({ + shopId, + shopName: shop.shopName, + items, + shippers: shopShippers, + }); + console.log({ shipping }); + } + + return { shipping: shipping.map((ship) => ShippingOptionDTO.transformShipment(ship)) }; + } + + //################################################################### + //################################################################### + async selectShipmentProvider(saveShippingInfo: SaveSellerShipmentInfoDTO, userId: string) { + const cart = (await this.cartRepo.getCartWithAggregate(userId))[0] as ICart; + if (!cart) throw new BadRequestError(CartMessage.CartNotFound); + + const { shipping: availableShipmentOptions } = await this.calculateAllShipmentOptions(userId); + + if (!availableShipmentOptions.length) throw new BadRequestError(SetShipmentMessage.ShipmentOptionsUnavailable); + + const { shipmentsInfo } = saveShippingInfo; + + // ensure shipment info matches the number of sellers in the cart + const shopIds = availableShipmentOptions.map((shipperOption) => shipperOption.shopId); + const shipmentShopIds = shipmentsInfo.map((shipInfo) => shipInfo.shopId); + + // validate that the selected shop IDs match the cart shop IDs + const unmatchedSellers = shipmentShopIds.filter((id) => !shopIds.includes(id)); + if (unmatchedSellers.length > 0) throw new BadRequestError([SetShipmentMessage.InvalidShipmentItems, unmatchedSellers.join(",")]); + + // process each seller's shipment info + for (const shipInfo of shipmentsInfo) { + const shopShipmentOptions = availableShipmentOptions.find((option) => option.shopId === shipInfo.shopId); + + if (!shopShipmentOptions) { + throw new BadRequestError([ + SetShipmentMessage.InvalidShipmentSellers, + `shop with ID ${shipInfo.shopId} does not have shipment options.`, + ]); + } + + const selectedShipper = shopShipmentOptions.shippers.find((shipper) => shipper.shipperId === shipInfo.shipmentId); + + if (!selectedShipper) { + throw new BadRequestError([ + SetShipmentMessage.ShipperNotAvailableForSeller, + `Shipper with ID ${shipInfo.shipmentId} is not available for shop ${shipInfo.shopId}.`, + ]); + } + // find existing CartShipmentItem for this seller or create a new one + const existingCartShipmentItem = await this.cartShipItemRepo.model.findOne({ + cart: cart._id.toString(), + shop: new Types.ObjectId(shipInfo.shopId), + }); + + const shipmentItems = selectedShipper.items.map((item) => ({ + product: item.product, + variant: new Types.ObjectId(item.variant), + quantity: item.quantity, + selling_price: item.selling_price, + retail_price: item.retail_price, + discount_percent: item.discount_percent, + shipmentCost: item.shippingCost, + isFreeShip: item.isFreeShip, + })); + + if (existingCartShipmentItem) { + // update the existing CartShipmentItem + existingCartShipmentItem.shipmentItems = shipmentItems; + existingCartShipmentItem.shipper = selectedShipper.shipperId; + + await existingCartShipmentItem.save(); + } else { + const totalSellingPrice = shipmentItems.reduce((acc, item) => acc + item.selling_price * item.quantity, 0); + const totalRetailPrice = shipmentItems.reduce((acc, item) => acc + item.retail_price * item.quantity, 0); + const totalShipmentCost = shipmentItems.reduce((acc, item) => acc + item.shipmentCost, 0); + + // create a new CartShipmentItem + await this.cartShipItemRepo.model.create({ + cart: cart._id.toString(), + shop: new Types.ObjectId(shipInfo.shopId), + shipmentItems, + shipper: selectedShipper.shipperId, + totalSellingPrice, + totalRetailPrice, + totalShipmentCost, + totalPaymentPrice: totalShipmentCost + totalSellingPrice, + itemsCount: shipmentItems.reduce((acc, item) => acc + item.quantity, 0), + }); + } + } + + // calculate the total shipping cost + const cartShipmentItems = await this.cartShipItemRepo.model.find({ cart: cart._id.toString() }).lean(); + const totalShippingCost = cartShipmentItems.reduce( + (sum, item) => sum + item.shipmentItems.reduce((subSum, shipmentItem) => subSum + shipmentItem.shipmentCost, 0), + 0, + ); + + return { + cartShipmentItems: cartShipmentItems, + shippingCost: totalShippingCost, + processCost: totalShippingCost, + payable_price: cart.payable_price + totalShippingCost * 2, + }; + } + + //===============================> helper method + // Helper function to calculate shipping days range + private calculateShippingDays(deliveryType: DeliveryType, deliveryTime: number): string[] { + const currentDate = new Date(); + + let minDeliveryDays = 0; + let maxDeliveryDays = 0; + + switch (deliveryType) { + case DeliveryType.SameDay: + minDeliveryDays = 0; + maxDeliveryDays = 1; + break; + case DeliveryType.Express: + minDeliveryDays = 1; + maxDeliveryDays = 2; + break; + case DeliveryType.Standard: + minDeliveryDays = 3; + maxDeliveryDays = 5; + break; + default: + minDeliveryDays = deliveryTime - 1; + maxDeliveryDays = deliveryTime; + break; + } + + const shippingDaysRange: string[] = []; + let processedDays = 0; + const totalDays = maxDeliveryDays - minDeliveryDays + 1; + + let i = minDeliveryDays; + while (processedDays < totalDays) { + const estimatedDate = new Date(currentDate); + estimatedDate.setDate(currentDate.getDate() + i); + + // check if the day is Thursday (4) or Friday (5) + const dayNumber = estimatedDate.getDay(); + + // skip Thursday (4) and Friday (5) + if (dayNumber === 4 || dayNumber === 5) { + i++; + continue; + } + + const options = { day: "numeric", weekday: "long", month: "long" }; + const dayName = estimatedDate.toLocaleString("fa-IR", options as any); + + shippingDaysRange.push(dayName); + processedDays++; // only count this day if it’s a valid shipping day + + i++; // move to the next day + } + + return shippingDaysRange; + } + + // Helper function to calculate the shipping cost for a product variant based on shipment provider + private async calculateShippingCost(shipmentProvider: IShipmentProviders, package_weight: number, quantity: number): Promise { + console.log({ package_weight }); + console.dir({ shipmentProvider: shipmentProvider.costs }, { depth: null }); + const cost = shipmentProvider.costs.find( + (cost) => package_weight / 1000 >= cost.weightRange.min && package_weight / 1000 <= cost.weightRange.max, + ); + console.log({ cost }); + + // if (!cost) throw new BadRequestError("No shipping cost available for this weight range"); + const defaulCost = cost ?? { cost_first: 100_000, cost_rest: 10_000 }; + const mainCost = defaulCost.cost_first; + const restCost = defaulCost.cost_rest * (quantity - 1); // multiply the rest cost by the remaining quantity + + return mainCost + restCost; + } + //helper function to calculate the delivery type based on time + private getDeliveryType(deliveryTime: number): DeliveryType { + if (deliveryTime <= 1) return DeliveryType.SameDay; + if (deliveryTime <= 2) return DeliveryType.Express; + return DeliveryType.Standard; + } +} + +export { ShipmentService }; diff --git a/src/modules/shipment/types/items.ts b/src/modules/shipment/types/items.ts new file mode 100644 index 0000000..7657963 --- /dev/null +++ b/src/modules/shipment/types/items.ts @@ -0,0 +1,4 @@ +import { IProduct } from "../../product/models/Abstraction/IProduct"; +import { IProductVariant } from "../../product/models/Abstraction/IProductVariant"; + +export type Items = { product: IProduct; variant: IProductVariant; quantity: number }; diff --git a/src/modules/shop/DTO/shop-search.dto.ts b/src/modules/shop/DTO/shop-search.dto.ts new file mode 100644 index 0000000..a3ccf4a --- /dev/null +++ b/src/modules/shop/DTO/shop-search.dto.ts @@ -0,0 +1,43 @@ +import { Expose } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; + +export class ShopSearchQueryDTO { + @Expose() + @IsOptional() + @IsInt() + @Max(50) + limit?: number; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @Expose() + @IsOptional() + @IsBoolean() + stock?: boolean; + + @Expose() + @IsOptional() + @IsInt() + maxPrice?: number; + + @Expose() + @IsOptional() + @IsInt() + minPrice?: number; + + @Expose() + @IsOptional() + @IsBoolean() + wholeSale?: boolean; + + @Expose() + @IsOptional() + @IsInt() + @Min(1) + @Max(7) + sort?: number; +} diff --git a/src/modules/shop/DTO/shop-update.dto.ts b/src/modules/shop/DTO/shop-update.dto.ts new file mode 100644 index 0000000..9d088a1 --- /dev/null +++ b/src/modules/shop/DTO/shop-update.dto.ts @@ -0,0 +1,44 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumberString, IsOptional, IsString, Length, MaxLength, MinLength } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class ShopUpdateDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "the name of shop", example: "لورم ایپسوم" }) + shopName?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @MaxLength(500) + @ApiProperty({ type: "string", description: "shop description", example: "shop description" }) + shopDescription?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @MinLength(11, { message: CommonMessage.TelephoneNumberIncorrect }) + @IsNumberString({ no_symbols: true }, { message: CommonMessage.TelephoneNumberIncorrect }) + @ApiProperty({ type: "string", description: "telephone number of company", example: "08633556698" }) + telephoneNumber?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.PostalIncorrect }) + @ApiProperty({ type: "string", description: "iran postal code format (10 char)", example: "5869568592" }) + @Length(10, 10, { message: CommonMessage.PostalIncorrect }) + shopPostalCode?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "logo url that uploaded", example: "https://logo.com" }) + logo?: string; +} diff --git a/src/modules/shop/DTO/shop.dto.ts b/src/modules/shop/DTO/shop.dto.ts new file mode 100644 index 0000000..c25e496 --- /dev/null +++ b/src/modules/shop/DTO/shop.dto.ts @@ -0,0 +1,73 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { AddressDTO } from "../../address/DTO/address.dto"; +import { ShipmentMethodDTO } from "../../shipment/DTO/shipment.dto"; +import { IShop } from "../models/Abstraction/IShop"; + +class ShopRateDTO { + @Expose() + totalRate: number; + + @Expose() + totalCount: number; + + @Expose() + onTimeShip: number; + + @Expose() + returns: number; + + @Expose() + cancel: number; +} +export class ShopDTO { + @Expose() + _id: string; + + @Expose() + shopName: string; + + @Expose() + shopCode: number; + + @Expose() + owner: string; + + @Expose() + shopDescription: string; + + @Expose() + telephoneNumber: string; + + @Expose() + isChatActive: boolean; + + @Expose() + shopPostalCode: string; + + @Expose() + logo: string; + + @Expose() + @Type(() => ShopRateDTO) + rate: ShopRateDTO; +} + +export class ShopInfoDTO extends ShopDTO { + @Expose() + @Type(() => ShipmentMethodDTO) + shipmentMethod: ShipmentMethodDTO[]; + + @Expose() + @Type(() => AddressDTO) + address: AddressDTO; + + public static transformShopInfo(data: IShop): ShopInfoDTO { + const shopInfoDTO = plainToInstance(ShopInfoDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return shopInfoDTO; + } +} diff --git a/src/modules/shop/models/Abstraction/IShop.ts b/src/modules/shop/models/Abstraction/IShop.ts new file mode 100644 index 0000000..4800e8d --- /dev/null +++ b/src/modules/shop/models/Abstraction/IShop.ts @@ -0,0 +1,29 @@ +import { Types } from "mongoose"; + +export enum OwnerRef { + SELLER = "Seller", + ADMIN = "Admin", +} + +export interface IShop { + shopName: string; + shopCode?: number; + shopDescription: string; + telephoneNumber: string; + shopPostalCode: string; + address: Types.ObjectId; + logo: string; + rate: IRate; + shipmentMethod: number[]; + isChatActive: boolean; + owner: Types.ObjectId; + ownerRef: OwnerRef; +} + +export interface IRate { + totalRate: number; + totalCount: number; + onTimeShip: number; + returns: number; + cancel: number; +} diff --git a/src/modules/shop/models/shop.model.ts b/src/modules/shop/models/shop.model.ts new file mode 100644 index 0000000..8faf2b4 --- /dev/null +++ b/src/modules/shop/models/shop.model.ts @@ -0,0 +1,44 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IRate, IShop, OwnerRef } from "./Abstraction/IShop"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const rateSchema = new Schema( + { + totalRate: { type: Number, default: 0 }, + totalCount: { type: Number, default: 0 }, + onTimeShip: { type: Number, default: 0 }, + returns: { type: Number, default: 0 }, + cancel: { type: Number, default: 0 }, + }, + { _id: false }, +); + +const ShopSchema = new Schema( + { + shopName: { type: String, trim: true, maxlength: 150, default: null }, + shopCode: { type: Number }, + shopDescription: { type: String, trim: true, maxlength: 500, default: null }, + telephoneNumber: { type: String, trim: true, default: null }, + shopPostalCode: { type: String, default: null }, + address: { type: Schema.Types.ObjectId, ref: "Address", default: null }, + logo: { type: String, trim: true, default: null }, + rate: { type: rateSchema }, + shipmentMethod: { type: [Number], ref: "Shipment", required: true }, + isChatActive: { type: Boolean, default: false }, + owner: { type: Schema.Types.ObjectId, required: true, refPath: "ownerRef" }, + ownerRef: { type: String, required: true, enum: OwnerRef }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); +ShopSchema.pre(["findOne", "find"], function (next) { + this.populate([{ path: "address" } /*{ path: "shipmentMethod" }*/]); + next(); +}); + +ShopSchema.index({ owner: 1, ownerRef: 1 }, { unique: true }); +ShopSchema.plugin(AutoIncrement, { id: "shopCode_in", inc_field: "shopCode", start_seq: 500 }); +const ShopModel = model("Shop", ShopSchema); +export { ShopModel }; diff --git a/src/modules/shop/shop.controller.ts b/src/modules/shop/shop.controller.ts new file mode 100644 index 0000000..95c52a5 --- /dev/null +++ b/src/modules/shop/shop.controller.ts @@ -0,0 +1,44 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, queryParam, request, requestParam } from "inversify-express-utils"; + +import { ShopService } from "./shop.service"; +import { HttpStatus } from "../../common"; +import { ShopSearchQueryDTO } from "./DTO/shop-search.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; + +@controller("/shop") +@ApiTags("Shop") +class ShopController extends BaseController { + @inject(IOCTYPES.ShopService) shopService: ShopService; + + @ApiOperation("get all products of a shop") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("shopCode", "code of shop", true) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("stock", "search based on product stock") + @ApiQuery("maxPrice", "search based on product priceRange") + @ApiQuery("minPrice", "search based on product priceRange") + @ApiQuery("wholeSale", "search based on product wholeSale if its enable") + @ApiQuery("sort", "sort option should be int from 1 to 7") + @ApiAuth() + @httpGet("/:shopCode/products", Guard.authOptional(), ValidationMiddleware.validateQuery(ShopSearchQueryDTO)) + public async getProductsOfShop( + @requestParam("shopCode") shopCode: string, + @queryParam() searchQueryDto: ShopSearchQueryDTO, + @request() req: Request, + ) { + const user = req.user as IUser; + const { count, ...data } = await this.shopService.getProductsOfShop(+shopCode, searchQueryDto, user?._id?.toString()); + const { pager } = this.paginate(count); + return this.response({ ...data, pager }); + } +} + +export { ShopController }; diff --git a/src/modules/shop/shop.repository.ts b/src/modules/shop/shop.repository.ts new file mode 100644 index 0000000..d8aa222 --- /dev/null +++ b/src/modules/shop/shop.repository.ts @@ -0,0 +1,17 @@ +import { IShop } from "./models/Abstraction/IShop"; +import { ShopModel } from "./models/shop.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class ShopRepo extends BaseRepository { + constructor() { + super(ShopModel); + } + + async findWithShopcode(shopCode: number) { + return this.model.findOne({ shopCode }, { _id: 1, shopName: 1, shopCode: 1, shopDescription: 1, logo: 1 }); + } +} + +export function createShopRepo(): ShopRepo { + return new ShopRepo(); +} diff --git a/src/modules/shop/shop.service.ts b/src/modules/shop/shop.service.ts new file mode 100644 index 0000000..77b0eac --- /dev/null +++ b/src/modules/shop/shop.service.ts @@ -0,0 +1,87 @@ +import { inject, injectable } from "inversify"; +import { ClientSession } from "mongoose"; + +import { ShopSearchQueryDTO } from "./DTO/shop-search.dto"; +import { ShopUpdateDTO } from "./DTO/shop-update.dto"; +import { ShopInfoDTO } from "./DTO/shop.dto"; +import { ShopRepo } from "./shop.repository"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ShipmentRepository } from "../shipment/shipment.repository"; +import { IRate, OwnerRef } from "./models/Abstraction/IShop"; +import { CommonMessage, ShopMessage } from "../../common/enums/message.enum"; +import { ProductDTO } from "../product/DTO/product.dto"; +import { IProduct } from "../product/models/Abstraction/IProduct"; +import { ProductRepository } from "../product/Repository/product"; + +@injectable() +class ShopService { + @inject(IOCTYPES.ShopRepo) shopRepo: ShopRepo; + @inject(IOCTYPES.ShipmentRepository) shipmentRepo: ShipmentRepository; + @inject(IOCTYPES.ProductRepository) productRepo: ProductRepository; + + async createDefaultShop(ownerId: string, ownerRef: OwnerRef, session: ClientSession) { + const shipmentMethods = await this.shipmentRepo.model.find().select("_id").session(session); + const shopRate: IRate = { + cancel: 0, + onTimeShip: 0, + returns: 0, + totalCount: 0, + totalRate: 0, + }; + const shop = await this.shopRepo.model.create( + [ + { + owner: ownerId, + ownerRef, + rate: shopRate, + shipmentMethod: shipmentMethods, + }, + ], + { session }, + ); + return { shop: shop[0] }; + } + + async getShopWithOwner(shopId: string) { + const shop = await this.shopRepo.model.findById(shopId).populate(["owner", "shipmentMethod"]); + return shop; + } + + async updateSellerShopInfo(ownerId: string, updateDto: ShopUpdateDTO) { + const shop = await this.shopRepo.model.findOne({ shopName: updateDto.shopName, owner: { $ne: ownerId } }); + if (shop) throw new BadRequestError(ShopMessage.ShopNameExist); + + const updatedShop = await this.shopRepo.model.findOneAndUpdate({ owner: ownerId }, { ...updateDto }, { new: true }); + if (!updatedShop) throw new BadRequestError(ShopMessage.ShopNotFound); + return { + message: CommonMessage.Updated, + }; + } + + async getShopInfo(ownerId: string, ownerRef: OwnerRef) { + const doc = await this.shopRepo.model.findOne({ owner: ownerId, ownerRef }).populate(["shipmentMethod", "address"]); + + if (!doc) throw new BadRequestError(ShopMessage.ShopNotFound); + + const shop = ShopInfoDTO.transformShopInfo(doc); + + return { + shop, + }; + } + + async getProductsOfShop(shopCode: number, queries: ShopSearchQueryDTO, userId?: string) { + const shop = await this.shopRepo.findWithShopcode(shopCode); + if (!shop) throw new BadRequestError(ShopMessage.NotFound); + + const priceRange = await this.productRepo.getPriceRangeByShop(shop._id.toString()); + + const { docs, count } = await this.productRepo.getProductsWithShop(shop._id.toString(), queries, userId); + const products = docs.map((doc: IProduct) => ProductDTO.transformProduct(doc)); + + return { shop, products, count, filters: { priceRange } }; + } +} + +export { ShopService }; diff --git a/src/modules/site-setting/DTO/siteSetting.dto.ts b/src/modules/site-setting/DTO/siteSetting.dto.ts new file mode 100644 index 0000000..0dff103 --- /dev/null +++ b/src/modules/site-setting/DTO/siteSetting.dto.ts @@ -0,0 +1,238 @@ +import { Expose, Type } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +class downloadAppValue { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "picture url", example: "https://google.com" }) + pic: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "url of download app", example: "https://google.com" }) + url: string; +} + +class socialMediaLinksValue { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "icon of Social media", example: "Telegram" }) + icon: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "url of download app", example: "https://google.com" }) + url: string; +} + +export class CreateSiteSettingDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "Site Url", example: "https://google.com" }) + siteLogo: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "phone of site", example: "09918914108" }) + footerPhone: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "email of site", example: "matin@gmail.com" }) + footerEmail: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "address of site", example: "اراک خیابان جنت" }) + footerAddress: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of site", + example: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است،", + }) + footerDescription: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of shipment", example: "رویه ارسال سندس کالا" }) + shipmentContent: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of faq", example: "سوالات متداول" }) + faqContent: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of policy", example: "حریم خصوصی" }) + policyContent: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of return rule", example: "رویه ارسال سندس کالا" }) + returnRulesContent: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of jobs", example: "به تیم ما بپیوندید" }) + jobsContent: string; + + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => downloadAppValue) + @ApiProperty({ + type: "Array", + description: "download app details of site", + example: [ + { pic: "https://google.com", url: "https://google.com" }, + { pic: "https://youtube.com", url: "https://youtube.com" }, + ], + }) + downloadAppDetails: downloadAppValue[]; + + @Expose() + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => socialMediaLinksValue) + @ApiProperty({ + type: "Array", + description: "social media details of site", + example: [ + { icon: "telegram", url: "https://web.telegram.com" }, + { icon: "youtube", url: "https://youtube.com/me" }, + ], + }) + socialMediaLinks: socialMediaLinksValue[]; +} + +export class UpdateSiteSettingDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "Site Url", example: "https://google.com" }) + siteLogo: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "phone of site", example: "09918914108" }) + footerPhone: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "email of site", example: "matin@gmail.com" }) + footerEmail: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "address of site", example: "اراک خیابان جنت" }) + footerAddress: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of shipment", example: "رویه ارسال سندس کالا" }) + shipmentContent: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of faq", example: "سوالات متداول" }) + faqContent: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of policy", example: "حریم خصوصی" }) + policyContent: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of return rule", example: "رویه ارسال سندس کالا" }) + returnRulesContent: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "content of jobs", example: "به تیم ما بپیوندید" }) + jobsContent: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "description of site", + example: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است،", + }) + footerDescription: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => downloadAppValue) + @ApiProperty({ + type: "Array", + description: "download app details of site", + example: [ + { pic: "https://google.com", url: "https://google.com" }, + { pic: "https://youtube.com", url: "https://youtube.com" }, + ], + }) + downloadAppDetails: downloadAppValue[]; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => socialMediaLinksValue) + @ApiProperty({ + type: "Array", + description: "social media details of site", + example: [ + { icon: "telegram", url: "https://web.telegram.com" }, + { icon: "youtube", url: "https://youtube.com/me" }, + ], + }) + socialMediaLinks: socialMediaLinksValue[]; +} diff --git a/src/modules/site-setting/model/Abstractions/ISiteSetting.ts b/src/modules/site-setting/model/Abstractions/ISiteSetting.ts new file mode 100644 index 0000000..84339a2 --- /dev/null +++ b/src/modules/site-setting/model/Abstractions/ISiteSetting.ts @@ -0,0 +1,24 @@ +export interface ISiteSetting { + siteLogo: string; + footerDescription: string; + footerAddress: string; + footerEmail: string; + footerPhone: string; + downloadAppDetails: [ + { + pic: string; + url: string; + }, + ]; + socialMediaLinks: [ + { + icon: string; + url: string; + }, + ]; + shipmentContent: string; + faqContent: string; + policyContent: string; + returnRulesContent: string; + jobsContent: string; +} diff --git a/src/modules/site-setting/model/siteSetting.model.ts b/src/modules/site-setting/model/siteSetting.model.ts new file mode 100644 index 0000000..5472633 --- /dev/null +++ b/src/modules/site-setting/model/siteSetting.model.ts @@ -0,0 +1,38 @@ +import { Schema, model } from "mongoose"; + +import { ISiteSetting } from "./Abstractions/ISiteSetting"; + +const SiteSettingSchema = new Schema( + { + siteLogo: { type: String, required: true }, + footerDescription: { type: String, required: true }, + footerAddress: { type: String, required: true }, + footerEmail: { type: String, required: true }, + footerPhone: { type: String, required: true }, + downloadAppDetails: [ + { + pic: { type: String, required: true }, + url: { type: String, required: true }, + }, + ], + socialMediaLinks: [ + { + icon: { type: String, required: true }, + url: { type: String, required: true }, + }, + ], + shipmentContent: { type: String, required: true }, + faqContent: { type: String, required: true }, + policyContent: { type: String, required: true }, + returnRulesContent: { type: String, required: true }, + jobsContent: { type: String, required: true }, + }, + { + timestamps: true, + toJSON: { versionKey: false, virtuals: true }, + id: false, + }, +); +const SiteSettingModel = model("SiteSetting", SiteSettingSchema); + +export { SiteSettingModel }; diff --git a/src/modules/site-setting/siteSetting.controller.ts b/src/modules/site-setting/siteSetting.controller.ts new file mode 100644 index 0000000..0279208 --- /dev/null +++ b/src/modules/site-setting/siteSetting.controller.ts @@ -0,0 +1,26 @@ +import { inject } from "inversify"; +import { controller, httpGet, queryParam } from "inversify-express-utils"; + +import { SiteSettingService } from "./siteSetting.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@controller("/site-setting") +@ApiTags("Site Setting") +export class SiteSettingController extends BaseController { + @inject(IOCTYPES.SiteSettingService) siteSettingService: SiteSettingService; + + @ApiOperation("get all Site setting") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery( + "name", + "the name of setting ==> value siteLogo | footerDescription | footerAddress | footerEmail | footerPhone | downloadAppDetails | socialMediaLinks | shipmentContent | faqContent | policyContent | returnRulesContent | jobsContent", + ) + @httpGet("") + public async getSiteSetting(@queryParam("name") name: string) { + const data = await this.siteSettingService.getSiteSetting(name); + return this.response(data); + } +} diff --git a/src/modules/site-setting/siteSetting.repository.ts b/src/modules/site-setting/siteSetting.repository.ts new file mode 100644 index 0000000..0f48898 --- /dev/null +++ b/src/modules/site-setting/siteSetting.repository.ts @@ -0,0 +1,13 @@ +import { ISiteSetting } from "./model/Abstractions/ISiteSetting"; +import { SiteSettingModel } from "./model/siteSetting.model"; +import { BaseRepository } from "../../common/base/repository"; + +export class SiteSettingRepo extends BaseRepository { + constructor() { + super(SiteSettingModel); + } +} + +export function CreateSiteSettingRepo(): SiteSettingRepo { + return new SiteSettingRepo(); +} diff --git a/src/modules/site-setting/siteSetting.service.ts b/src/modules/site-setting/siteSetting.service.ts new file mode 100644 index 0000000..a5e0dc4 --- /dev/null +++ b/src/modules/site-setting/siteSetting.service.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from "inversify"; + +import { CreateSiteSettingDTO, UpdateSiteSettingDTO } from "./DTO/siteSetting.dto"; +import { SiteSettingRepo } from "./siteSetting.repository"; +import { CommonMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class SiteSettingService { + @inject(IOCTYPES.SiteSettingRepo) siteSettingRepo: SiteSettingRepo; + + async createSiteSetting(createDto: CreateSiteSettingDTO) { + const existSiteSetting = await this.siteSettingRepo.findAll(); + if (existSiteSetting.length > 0) throw new BadRequestError(CommonMessage.SettingExists); + const siteSetting = await this.siteSettingRepo.model.create(createDto); + return { siteSetting }; + } + + async getSiteSetting(name?: string) { + const projection = name ? { [name]: 1 } : {}; + const siteSetting = await this.siteSettingRepo.model.findOne({}, projection); + return { siteSetting }; + } + + async updateSiteSetting(updateDto: UpdateSiteSettingDTO) { + const siteSetting = await this.siteSettingRepo.model.findOneAndUpdate({}, updateDto, { new: true }); + return { siteSetting }; + } +} diff --git a/src/modules/ticket/DTO/createTicket.dto.ts b/src/modules/ticket/DTO/createTicket.dto.ts new file mode 100644 index 0000000..eeb242b --- /dev/null +++ b/src/modules/ticket/DTO/createTicket.dto.ts @@ -0,0 +1,45 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { TicketCategoryModel } from "../models/ticketCategory.model"; + +export class CreateTicketDTO { + @Expose() + @IsNotEmpty() + @IsString() + @IsValidId(TicketCategoryModel) + @ApiProperty({ type: "string", description: "the cat id of ticket", example: "9a318e1a46838b519aed2f57c95944b4c79bd12a938d278d" }) + category: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the subject of ticket", example: "درخواست برداشت" }) + subject: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the message content of ticket", example: "مشکل دارم در درخواست برداشت" }) + content: string; +} + +export class CreateTicketCategoryDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "the title_fa of category", example: "ارتباط با واحد فروش" }) + title_fa: string; + + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ + type: "string", + description: "the subject of ticket", + example: "مشکلات مربوط به فروش و مالی و بازیابی ", + }) + description: string; +} diff --git a/src/modules/ticket/DTO/ticketMessage.dto.ts b/src/modules/ticket/DTO/ticketMessage.dto.ts new file mode 100644 index 0000000..40e65fb --- /dev/null +++ b/src/modules/ticket/DTO/ticketMessage.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class TicketMessageDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "ticket message", example: "مشکل در اضافه کردن محصول" }) + content: string; + + @Expose() + @IsOptional() + @IsString({ each: true }) + @ApiProperty({ type: "array", description: "attachments", example: ["url1", "url2"] }) + attachments: string[]; +} diff --git a/src/modules/ticket/models/Abstraction/ITicket.ts b/src/modules/ticket/models/Abstraction/ITicket.ts new file mode 100644 index 0000000..4641412 --- /dev/null +++ b/src/modules/ticket/models/Abstraction/ITicket.ts @@ -0,0 +1,16 @@ +import { Types } from "mongoose"; + +export enum TicketStatus { + PENDING = "pending", + ANSWERED = "answered", + CLOSED = "closed", +} + +export interface ITicket { + _id: Types.ObjectId; + numericId: number; + seller: Types.ObjectId; + category: Types.ObjectId; + subject: string; + status: TicketStatus; +} diff --git a/src/modules/ticket/models/Abstraction/ITicketCategory.ts b/src/modules/ticket/models/Abstraction/ITicketCategory.ts new file mode 100644 index 0000000..29f5821 --- /dev/null +++ b/src/modules/ticket/models/Abstraction/ITicketCategory.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface ITicketCategory { + _id: Types.ObjectId; + title_fa: string; + description: string; +} diff --git a/src/modules/ticket/models/Abstraction/ITicketMessage.ts b/src/modules/ticket/models/Abstraction/ITicketMessage.ts new file mode 100644 index 0000000..2d52527 --- /dev/null +++ b/src/modules/ticket/models/Abstraction/ITicketMessage.ts @@ -0,0 +1,15 @@ +import { Types } from "mongoose"; + +export enum SenderRef { + SELLER = "Seller", + ADMIN = "Admin", +} + +export interface ITicketMessage { + _id: Types.ObjectId; + ticket: Types.ObjectId; + sender: Types.ObjectId; + senderRef: SenderRef; + content: string; + attachments: string[]; +} diff --git a/src/modules/ticket/models/ticket.model.ts b/src/modules/ticket/models/ticket.model.ts new file mode 100644 index 0000000..8c9d6ec --- /dev/null +++ b/src/modules/ticket/models/ticket.model.ts @@ -0,0 +1,33 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { ITicket, TicketStatus } from "./Abstraction/ITicket"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const TicketSchema = new Schema( + { + numericId: { type: Number }, + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true }, + category: { type: Schema.Types.ObjectId, ref: "TicketCategory", required: true }, + subject: { type: String, required: true }, + status: { type: String, enum: TicketStatus, default: TicketStatus.PENDING }, + // admin: { type: Schema.Types.ObjectId, ref: "Admin" }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +TicketSchema.pre(["findOne", "find"], function (next) { + this.populate({ path: "seller", select: { _id: 1, fullName: 1, email: 1, phoneNumber: 1 } }); + next(); +}); + +TicketSchema.pre(["findOne", "find"], function (next) { + this.populate({ path: "category" }); + next(); +}); + +TicketSchema.plugin(AutoIncrement, { id: "ticket_id", inc_field: "numericId" }); +const TicketModel = model("Ticket", TicketSchema); + +export { TicketModel }; diff --git a/src/modules/ticket/models/ticketCategory.model.ts b/src/modules/ticket/models/ticketCategory.model.ts new file mode 100644 index 0000000..6be1117 --- /dev/null +++ b/src/modules/ticket/models/ticketCategory.model.ts @@ -0,0 +1,15 @@ +import { Schema, model } from "mongoose"; + +import { ITicketCategory } from "./Abstraction/ITicketCategory"; + +const TicketCategorySchema = new Schema( + { + title_fa: { type: String, required: true }, + description: { type: String, required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const TicketCategoryModel = model("TicketCategory", TicketCategorySchema); + +export { TicketCategoryModel }; diff --git a/src/modules/ticket/models/ticketMessage.model.ts b/src/modules/ticket/models/ticketMessage.model.ts new file mode 100644 index 0000000..3fb0fb4 --- /dev/null +++ b/src/modules/ticket/models/ticketMessage.model.ts @@ -0,0 +1,23 @@ +import { Schema, model } from "mongoose"; + +import { ITicketMessage, SenderRef } from "./Abstraction/ITicketMessage"; + +const TicketMessageSchema = new Schema( + { + ticket: { type: Schema.Types.ObjectId, ref: "Ticket", required: true }, + sender: { type: Schema.Types.ObjectId, refPath: "senderRef", required: true }, + senderRef: { type: String, enum: SenderRef, required: true }, + content: { type: String, required: true }, + attachments: [{ type: String }], + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +TicketMessageSchema.pre(["findOne", "find"], function (next) { + this.populate({ path: "sender", select: { _id: 1, fullName: 1, email: 1 } }); + next(); +}); + +const TicketMessageModel = model("TicketMessage", TicketMessageSchema); + +export { TicketMessageModel }; diff --git a/src/modules/ticket/repository/ticket.ts b/src/modules/ticket/repository/ticket.ts new file mode 100644 index 0000000..3fedb6a --- /dev/null +++ b/src/modules/ticket/repository/ticket.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ITicket } from "../models/Abstraction/ITicket"; +import { TicketModel } from "../models/ticket.model"; + +class TicketRepository extends BaseRepository { + constructor() { + super(TicketModel); + } + + async getSellerTickets(sellerId: string, queries: { status: string; categoryId: string }) { + const findQuery = { + seller: sellerId, + ...(queries.status && { + status: queries.status, + }), + ...(queries.categoryId && { + category: queries.categoryId, + }), + }; + return this.model.find(findQuery).sort({ createdAt: -1 }); + } +} + +function createTicketRepo(): TicketRepository { + return new TicketRepository(); +} + +export { TicketRepository, createTicketRepo }; diff --git a/src/modules/ticket/repository/ticketCategory.ts b/src/modules/ticket/repository/ticketCategory.ts new file mode 100644 index 0000000..64391a8 --- /dev/null +++ b/src/modules/ticket/repository/ticketCategory.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ITicketCategory } from "../models/Abstraction/ITicketCategory"; +import { TicketCategoryModel } from "../models/ticketCategory.model"; + +class TicketCategoryRepository extends BaseRepository { + constructor() { + super(TicketCategoryModel); + } +} + +function createTicketCategoryRepo(): TicketCategoryRepository { + return new TicketCategoryRepository(); +} + +export { TicketCategoryRepository, createTicketCategoryRepo }; diff --git a/src/modules/ticket/repository/ticketMessage.ts b/src/modules/ticket/repository/ticketMessage.ts new file mode 100644 index 0000000..a9c5a26 --- /dev/null +++ b/src/modules/ticket/repository/ticketMessage.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ITicketMessage } from "../models/Abstraction/ITicketMessage"; +import { TicketMessageModel } from "../models/ticketMessage.model"; + +class TicketMessageRepository extends BaseRepository { + constructor() { + super(TicketMessageModel); + } +} + +function createTicketMessageRepo(): TicketMessageRepository { + return new TicketMessageRepository(); +} + +export { TicketMessageRepository, createTicketMessageRepo }; diff --git a/src/modules/ticket/ticket.controller.ts b/src/modules/ticket/ticket.controller.ts new file mode 100644 index 0000000..c662051 --- /dev/null +++ b/src/modules/ticket/ticket.controller.ts @@ -0,0 +1,114 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody, requestParam } from "inversify-express-utils"; + +import { CreateTicketDTO } from "./DTO/createTicket.dto"; +import { TicketService } from "./ticket.service"; +import { HttpStatus } from "../../common"; +import { TicketMessageDTO } from "./DTO/ticketMessage.dto"; +import { TicketStatus } from "./models/Abstraction/ITicket"; +import { SenderRef } from "./models/Abstraction/ITicketMessage"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; + +@controller("/tickets") +@ApiTags("Ticket") +class TicketController extends BaseController { + @inject(IOCTYPES.TicketService) ticketService: TicketService; + + @ApiOperation("get all ticket category") + @ApiResponse("successful", HttpStatus.Ok) + @httpGet("/categories") + public async getTicketCategories() { + const data = await this.ticketService.getTicketCategories(); + return this.response(data); + } + + @ApiOperation("get all tickets of seller current session seller == > login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("status", "status of ticket ==> closed | pending") + @ApiQuery("categoryId", "id of ticket category") + @ApiAuth() + @httpGet("", Guard.authSeller()) + public async getAllTickets(@request() req: Request, @queryParam("status") status: string, @queryParam("categoryId") categoryId: string) { + const seller = req.user as ISeller; + const data = await this.ticketService.getAllSellerTickets(seller._id.toString(), { status, categoryId }); + return this.response(data); + } + + @ApiOperation("create ticket for seller ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateTicketDTO) + @ApiAuth() + @httpPost("/create", Guard.authSeller(), ValidationMiddleware.validateInput(CreateTicketDTO)) + public async createTicket(@request() req: Request, @requestBody() createDto: CreateTicketDTO) { + const seller = req.user as ISeller; + const data = await this.ticketService.createTicket(seller._id.toString(), createDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("add a message to ticket of seller ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiParam("id", "id of ticket", true) + @ApiModel(TicketMessageDTO) + @ApiAuth() + @httpPost("/:id/add-message", Guard.authSeller(), ValidationMiddleware.validateInput(TicketMessageDTO)) + public async addMessageToTicket( + @requestParam("id") ticketId: string, + @request() req: Request, + @requestBody() messageDto: TicketMessageDTO, + ) { + const seller = req.user as ISeller; + const data = await this.ticketService.addMessage(ticketId, seller._id.toString(), SenderRef.SELLER, messageDto); + return this.response(data, HttpStatus.Created); + } + + @ApiOperation("get ticket with its messages of seller with its id ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of ticket", true) + @ApiAuth() + @httpGet("/:id", Guard.authSeller()) + public async getTicket(@requestParam("id") ticketId: string, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.ticketService.getTicketWithMessages(ticketId, seller._id.toString()); + return this.response(data); + } + + @ApiOperation("close a status of seller ticket with its id ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiParam("id", "id of ticket", true) + @ApiAuth() + @httpPost("/:id/close", Guard.authSeller()) + public async closeTicket(@requestParam("id") ticketId: string, @request() req: Request) { + const seller = req.user as ISeller; + const data = await this.ticketService.updateTicketStatus(ticketId, TicketStatus.CLOSED, seller._id.toString()); + return this.response(data); + } + + @ApiOperation("upload a ticket attachment") + @ApiResponse("successful", HttpStatus.Created) + @ApiFile("attachments", true, true) + @ApiAuth() + @httpPost("/attachment", Guard.authSeller(), UploadService.multiple("attachments", 5, "ticket-attachments")) + public async ticketAttachment(@request() req: Request) { + // eslint-disable-next-line no-undef + const files = req.files as Express.MulterS3.File[]; + const data = { + message: "files uploaded!", + urls: files.map((file) => ({ + originalname: file.originalname, + size: file.size, + url: file.location, + type: file.mimetype, + })), + }; + return this.response(data, HttpStatus.Accepted); + } +} + +export { TicketController }; diff --git a/src/modules/ticket/ticket.service.ts b/src/modules/ticket/ticket.service.ts new file mode 100644 index 0000000..9abc0ef --- /dev/null +++ b/src/modules/ticket/ticket.service.ts @@ -0,0 +1,177 @@ +import { inject, injectable } from "inversify"; +import { startSession } from "mongoose"; + +import { CreateTicketCategoryDTO, CreateTicketDTO } from "./DTO/createTicket.dto"; +import { TicketMessageDTO } from "./DTO/ticketMessage.dto"; +import { ITicket, TicketStatus } from "./models/Abstraction/ITicket"; +import { SenderRef } from "./models/Abstraction/ITicketMessage"; +import { TicketRepository } from "./repository/ticket"; +import { TicketCategoryRepository } from "./repository/ticketCategory"; +import { TicketMessageRepository } from "./repository/ticketMessage"; +import { CommonMessage, TicketMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { NotificationService } from "../notification/notification.service"; + +@injectable() +class TicketService { + @inject(IOCTYPES.TicketRepository) ticketRepo: TicketRepository; + @inject(IOCTYPES.TicketMessageRepository) ticketMessageRepo: TicketMessageRepository; + @inject(IOCTYPES.TicketCategoryRepository) ticketCategoryRepo: TicketCategoryRepository; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + + async createTicketCategory(createDto: CreateTicketCategoryDTO) { + const category = await this.ticketCategoryRepo.model.create(createDto); + return { + message: CommonMessage.Created, + category, + }; + } + + async getUnreadTickerCount(sellerId: string) { + return this.ticketRepo.model.countDocuments({ seller: sellerId, status: TicketStatus.PENDING }); + } + + //###################################################### + //###################################################### + async getTicketCategories() { + const categories = await this.ticketCategoryRepo.findAll(); + return { categories }; + } + //###################################################### + //###################################################### + async getAllTickets() { + const tickets = await this.ticketRepo.findAll(); + return { tickets }; + } + async getTicketWithMessageForAdmin(ticketId: string) { + const ticket = await this.ticketRepo.model.findOne({ _id: ticketId }); + const messages = await this.ticketMessageRepo.model.find({ ticket: ticketId }, {}, { sort: { createdAt: -1 } }); + return { ticket, messages }; + } + //###################################################### + //###################################################### + async getAllSellerTickets(sellerId: string, queries: { status: string; categoryId: string }) { + const tickets = await this.ticketRepo.getSellerTickets(sellerId, queries); + return { tickets }; + } + //###################################################### + //###################################################### + async createTicket(sellerId: string, createDto: CreateTicketDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const ticket = await this.ticketRepo.model.create( + [ + { + seller: sellerId, + category: createDto.category, + subject: createDto.subject, + }, + ], + { session }, + ); + + const ticketMessage = await this.ticketMessageRepo.model.create( + [ + { + ticket: ticket[0]._id, + sender: sellerId, + content: createDto.content, + senderRef: SenderRef.SELLER, + }, + ], + { session }, + ); + + const notif = await this.notificationService.notifyCreateTicket(session); + console.log(notif); + await session.commitTransaction(); + + return { + message: CommonMessage.Created, + ticket: ticket[0], + ticketMessage: ticketMessage[0], + }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + //###################################################### + //###################################################### + async addMessage(ticketId: string, senderId: string, senderRef: SenderRef, messageDto: TicketMessageDTO) { + let ticket: ITicket | null; + if (senderRef === SenderRef.SELLER) { + ticket = (await this.validateTicket(ticketId, senderId)).ticket; + await this.ticketRepo.model.findByIdAndUpdate(ticket._id, { status: TicketStatus.PENDING }); + } else { + ticket = await this.ticketRepo.findById(ticketId); + if (!ticket) throw new BadRequestError(TicketMessage.TicketNotFound); + await this.ticketRepo.model.findByIdAndUpdate(ticket._id, { status: TicketStatus.ANSWERED }); + } + // if (messageDto.attachments) { + // await this.ticketRepo.model.findByIdAndUpdate(ticketId, { $push: { attachments: messageDto.attachments } }); + // } + + const newMessage = await this.ticketMessageRepo.model.create({ + ticket: ticket._id, + sender: senderId, + senderRef: senderRef, + content: messageDto.content, + attachments: messageDto.attachments, + }); + + return { + message: CommonMessage.Created, + newMessage, + }; + } + //###################################################### + //###################################################### + async getTicketWithMessages(ticketId: string, sellerId: string) { + const ticket = await this.ticketRepo.model.findOne({ _id: ticketId, seller: sellerId }); + const messages = await this.ticketMessageRepo.model.find({ ticket: ticketId }, {}, { sort: { createdAt: -1 } }); + return { ticket, messages }; + } + //###################################################### + //###################################################### + async updateTicketStatus(ticketId: string, status: TicketStatus, sellerId?: string) { + if (sellerId) { + await this.validateTicket(ticketId, sellerId); + const ticket = await this.ticketRepo.model.findByIdAndUpdate(ticketId, { status }, { new: true }); + return { + message: CommonMessage.Updated, + ticket, + }; + } + const ticket = await this.ticketRepo.model.findByIdAndUpdate(ticketId, { status }, { new: true }); + if (!ticket) throw new BadRequestError(TicketMessage.TicketNotFound); + + return { + message: CommonMessage.Updated, + ticket, + }; + } + + //******************************************* + //******************************************* + //helper method + private async validateTicket(ticketId: string, sellerId: string) { + const ticket = await this.ticketRepo.findById(ticketId); + + if (!ticket) throw new BadRequestError(TicketMessage.TicketNotFound); + + if (ticket.seller._id.toString() !== sellerId) throw new BadRequestError(TicketMessage.TicketNotBelongToSeller); + + const messages = await this.ticketMessageRepo.model.find({ ticket: ticketId }, {}, { sort: { createdAt: -1 } }); + return { + ticket, + messages, + }; + } +} + +export { TicketService }; diff --git a/src/modules/token/models/Abstraction/IToken.ts b/src/modules/token/models/Abstraction/IToken.ts new file mode 100644 index 0000000..6d8c54b --- /dev/null +++ b/src/modules/token/models/Abstraction/IToken.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface IToken { + userId: Types.ObjectId; + refreshToken: string; + refreshTokenExpire: Date; +} diff --git a/src/modules/token/models/token.model.ts b/src/modules/token/models/token.model.ts new file mode 100644 index 0000000..2d7b6f6 --- /dev/null +++ b/src/modules/token/models/token.model.ts @@ -0,0 +1,16 @@ +import { Schema, model } from "mongoose"; + +import { IToken } from "./Abstraction/IToken"; + +const tokenSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, + refreshToken: String, + refreshTokenExpire: Date, + }, + { timestamps: true, id: false }, +); + +const TokenModel = model("Token", tokenSchema); + +export { TokenModel }; diff --git a/src/modules/token/token.repository.ts b/src/modules/token/token.repository.ts new file mode 100644 index 0000000..aafcce7 --- /dev/null +++ b/src/modules/token/token.repository.ts @@ -0,0 +1,23 @@ +import { IToken } from "./models/Abstraction/IToken"; +import { TokenModel } from "./models/token.model"; +import { BaseRepository } from "../../common/base/repository"; + +class TokenRepository extends BaseRepository { + constructor() { + super(TokenModel); + } + + async findByToken(refreshToken: string) { + return this.model.findOne({ refreshToken }); + } + + async findByUserId(id: string) { + return this.model.findOne({ userId: id }); + } +} + +function createTokenRepository(): TokenRepository { + return new TokenRepository(); +} + +export { TokenRepository, createTokenRepository }; diff --git a/src/modules/token/token.service.ts b/src/modules/token/token.service.ts new file mode 100644 index 0000000..be3ccbf --- /dev/null +++ b/src/modules/token/token.service.ts @@ -0,0 +1,67 @@ +import { randomBytes } from "node:crypto"; + +import DayJs from "dayjs"; +import { inject, injectable } from "inversify"; +import jwt, { SignOptions, TokenExpiredError } from "jsonwebtoken"; + +import { TokenRepository } from "./token.repository"; +import { AuthTokenPayload, TokenType } from "../../common/types/jwt.type"; +import { jwtExpiredErr } from "../../core/app/app.errors"; +import { Logger } from "../../core/logging/logger"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +export class TokenService { + private logger = new Logger(TokenService.name); + @inject(IOCTYPES.TokenRepository) private tokenRepository: TokenRepository; + private jwtSecret: string = process.env.JWT_SECRET; + private jwt_access_expire: string = process.env.JWT_EXPIRE_ACCESS || "2"; //hours + private jwt_refresh_expire: string = process.env.JWT_EXPIRE_REFRESH || "2"; //day + + public _generateToken(type: TokenType, payload: AuthTokenPayload, option?: SignOptions) { + if (type == "Access" && option?.expiresIn) return jwt.sign(payload, this.jwtSecret, { ...option }); + const refToken = randomBytes(24).toString("hex"); + return refToken; + } + public async generateAuthTokens(payload: AuthTokenPayload) { + const accessToken = this._generateToken("Access", payload, { expiresIn: `${this.jwt_access_expire}h` }); + const refreshToken = this._generateToken("Refresh", payload); + const RefreshExpire = DayJs().add(+this.jwt_refresh_expire, "day").toDate(); + + const oldToken = await this.tokenRepository.findByUserId(payload.sub); + if (oldToken) { + await this.tokenRepository.delete(oldToken._id.toString()); + } + await this.tokenRepository.model.create({ refreshToken, userId: payload.sub, refreshTokenExpire: RefreshExpire }); + + const tokens = { + accessToken: { + token: accessToken, + expires: DayJs().add(+this.jwt_access_expire, "h").unix().toString(), + }, + refreshToken: { + token: refreshToken, + expires: DayJs().add(+this.jwt_refresh_expire, "day").unix().toString(), + }, + }; + return tokens; + } + + public async revokeTokens(id: string) { + // const deletedToken = await this.tokenRepository.delete(id); + // if (!deletedToken) return false; + // return true; + + return !!(await this.tokenRepository.delete(id)); + } + public verifyToken(token: string): AuthTokenPayload { + try { + const decoded = jwt.verify(token, this.jwtSecret); + return decoded as AuthTokenPayload; + } catch (error) { + if (error instanceof TokenExpiredError) throw new jwtExpiredErr(); + this.logger.error("error in jwt verify", error); + throw error; + } + } +} diff --git a/src/modules/user/DTO/comment.dto.ts b/src/modules/user/DTO/comment.dto.ts new file mode 100644 index 0000000..d9c862f --- /dev/null +++ b/src/modules/user/DTO/comment.dto.ts @@ -0,0 +1,50 @@ +import { Expose, Transform, Type, plainToInstance } from "class-transformer"; + +import { MiniUserDTO } from "./user.dto"; +import { TimeService } from "../../../utils/time.service"; +import { ProductDTO } from "../../product/DTO/product.dto"; +import { IComment } from "../../product/models/Abstraction/IComment"; + +export class CommentDTO { + @Expose() + _id: string; + + @Expose() + title: string; + + @Expose() + advantage: string[]; + + @Expose() + disAdvantage: string[]; + + @Expose() + content: string; + + @Expose() + status: string; + + @Expose() + rate: number; + + @Expose() + @Type(() => MiniUserDTO) + user: MiniUserDTO; + + @Expose() + @Type(() => ProductDTO) + product: ProductDTO; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformComment(data: IComment): CommentDTO { + const commentDTO = plainToInstance(CommentDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return commentDTO; + } +} diff --git a/src/modules/user/DTO/question.dto.ts b/src/modules/user/DTO/question.dto.ts new file mode 100644 index 0000000..290f592 --- /dev/null +++ b/src/modules/user/DTO/question.dto.ts @@ -0,0 +1,51 @@ +import { Expose, Transform, Type, plainToInstance } from "class-transformer"; + +import { MiniUserDTO } from "./user.dto"; +import { TimeService } from "../../../utils/time.service"; +import { ProductDTO } from "../../product/DTO/product.dto"; +import { IQuestion } from "../../product/models/Abstraction/IQuestion"; + +export class QuestionAnswerDTO { + @Expose() + content: string; + + @Expose() + @Type(() => MiniUserDTO) + user: MiniUserDTO; +} + +export class QuestionDTO { + @Expose() + _id: string; + + @Expose() + content: string; + + @Expose() + status: string; + + @Expose() + @Type(() => ProductDTO) + product: ProductDTO; + + @Expose() + @Type(() => MiniUserDTO) + user: MiniUserDTO; + + @Expose() + @Type(() => QuestionAnswerDTO) + answers: QuestionAnswerDTO[]; + + @Expose() + @Transform(({ value }) => TimeService.convertGregorianToPersian(new Date(value), true)) + createdAt: string; + + public static transformQuestion(data: IQuestion): QuestionDTO { + const questionDTO = plainToInstance(QuestionDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return questionDTO; + } +} diff --git a/src/modules/user/DTO/user.dto.ts b/src/modules/user/DTO/user.dto.ts new file mode 100644 index 0000000..86bb384 --- /dev/null +++ b/src/modules/user/DTO/user.dto.ts @@ -0,0 +1,45 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { AddressDTO } from "../../address/DTO/address.dto"; +import { IUser } from "../models/Abstraction/IUser"; + +export class UserDTO { + @Expose() + _id: string; + + @Expose() + fullName: string; + + @Expose() + phoneNumber: string; + + @Expose() + dateOfBirth: string; + + @Expose() + @Type(() => AddressDTO) + address: AddressDTO; + + @Expose() + nationalCode: string; + + @Expose() + email: string; + + @Expose() + homeNumber: string; + + public static transformUser(data: IUser): UserDTO { + const userDTO = plainToInstance(UserDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return userDTO; + } +} + +export class MiniUserDTO { + @Expose() + fullName: string; +} diff --git a/src/modules/user/DTO/userUpdate.dto.ts b/src/modules/user/DTO/userUpdate.dto.ts new file mode 100644 index 0000000..930b61b --- /dev/null +++ b/src/modules/user/DTO/userUpdate.dto.ts @@ -0,0 +1,66 @@ +import { Expose } from "class-transformer"; +import { IsEmail, IsMobilePhone, IsNotEmpty, IsNumberString, IsOptional, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidPersianDate } from "../../../common/decorator/validation.decorator"; +import { AuthMessage, CommonMessage } from "../../../common/enums/message.enum"; + +export class UpdateUserDTO { + @Expose() + @IsOptional() + @IsNotEmpty({ message: CommonMessage.NameNotEmpty }) + @ApiProperty({ type: "string", description: "fullname of user", example: "مهیار گودرزی" }) + fullName?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsValidPersianDate() + @ApiProperty({ type: "string", description: "dataof birth", example: "1375/02/05" }) + dateOfBirth?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.NationalCodeIncorrect }) + @ApiProperty({ type: "string", description: "iranian format (10 char)", example: "4569852169" }) + @Length(10, 10, { message: CommonMessage.NationalCodeIncorrect }) + nationalCode?: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @IsNumberString({ no_symbols: true }, { message: CommonMessage.TelephoneNumberIncorrect }) + @ApiProperty({ type: "string", description: "telephone number of user", example: "08633556698" }) + homeNumber?: string; +} + +export class ChangeEmailDTO { + @Expose() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email of seller", example: "shop@email.com" }) + email: string; +} + +export class VerifyEmailDTO { + @Expose() + @IsNotEmpty({ message: AuthMessage.EmailNotEmpty }) + @IsEmail(undefined, { message: AuthMessage.IncorrectEmail }) + @ApiProperty({ type: "string", description: "email of seller", example: "shop@email.com" }) + email: string; + + @Expose() + @IsNotEmpty({ message: AuthMessage.OtpNotEmpty }) + @Length(5, 5, { message: AuthMessage.OtpCodeLength }) + @ApiProperty({ type: "string", description: "The otpcode", example: "52648" }) + otpCode: string; +} + +export class UpdatePhoneDTO { + @Expose() + @IsNotEmpty({ message: AuthMessage.NumberNotEmpty }) + @IsMobilePhone("fa-IR", undefined, { message: AuthMessage.IncorrectNumber }) + @ApiProperty({ type: "string", description: "phone number", example: "09121601252" }) + phoneNumber: string; +} diff --git a/src/modules/user/DTO/wishlist.dto.ts b/src/modules/user/DTO/wishlist.dto.ts new file mode 100644 index 0000000..2311e67 --- /dev/null +++ b/src/modules/user/DTO/wishlist.dto.ts @@ -0,0 +1,31 @@ +import { Expose, Type, plainToInstance } from "class-transformer"; + +import { UserDTO } from "./user.dto"; +import { ProductDTO, ProductDetailVariantDTO } from "../../product/DTO/product.dto"; +import { IWishlist } from "../models/Abstraction/IWishlist"; + +export class WishlistDTO { + @Expose() + _id: string; + + @Expose() + @Type(() => UserDTO) + user: UserDTO; + + @Expose() + @Type(() => ProductDTO) + product: ProductDTO; + + @Expose() + @Type(() => ProductDetailVariantDTO) + variant: ProductDetailVariantDTO; + + public static transformWishlist(data: IWishlist): WishlistDTO { + const wishlistDTO = plainToInstance(WishlistDTO, data, { + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); + + return wishlistDTO; + } +} diff --git a/src/modules/user/models/Abstraction/IUser.ts b/src/modules/user/models/Abstraction/IUser.ts new file mode 100644 index 0000000..eef2233 --- /dev/null +++ b/src/modules/user/models/Abstraction/IUser.ts @@ -0,0 +1,12 @@ +import { Types } from "mongoose"; + +export interface IUser { + _id: Types.ObjectId; + fullName: string; + phoneNumber: string; + dateOfBirth: string; + address: Types.ObjectId; + nationalCode: string; + email: string; + homeNumber: string; +} diff --git a/src/modules/user/models/Abstraction/IWishlist.ts b/src/modules/user/models/Abstraction/IWishlist.ts new file mode 100644 index 0000000..079f3c0 --- /dev/null +++ b/src/modules/user/models/Abstraction/IWishlist.ts @@ -0,0 +1,7 @@ +import { Types } from "mongoose"; + +export interface IWishlist { + user: Types.ObjectId; + product: number; + variant: Types.ObjectId; +} diff --git a/src/modules/user/models/user.model.ts b/src/modules/user/models/user.model.ts new file mode 100644 index 0000000..244019c --- /dev/null +++ b/src/modules/user/models/user.model.ts @@ -0,0 +1,24 @@ +import { Schema, model } from "mongoose"; + +import { IUser } from "./Abstraction/IUser"; + +const userSchema = new Schema( + { + fullName: { type: String, required: true }, + phoneNumber: { type: String, unique: true, sparse: true }, + dateOfBirth: { type: String, default: null }, + address: { type: Schema.Types.ObjectId, ref: "Address", default: null }, + nationalCode: { type: String, default: null }, + email: { type: String, unique: true, sparse: true }, + homeNumber: { type: String, default: null }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +const UserModel = model("User", userSchema); + +export { UserModel }; diff --git a/src/modules/user/models/wishlist.model.ts b/src/modules/user/models/wishlist.model.ts new file mode 100644 index 0000000..7a71462 --- /dev/null +++ b/src/modules/user/models/wishlist.model.ts @@ -0,0 +1,28 @@ +import { Schema, model } from "mongoose"; + +import { IWishlist } from "./Abstraction/IWishlist"; + +const WishlistSchema = new Schema( + { + user: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true }, + product: { type: Number, ref: "Product", required: true }, + variant: { type: Schema.Types.ObjectId, ref: "ProductVariant", required: true }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +WishlistSchema.pre(["find", "findOne"], function (next) { + this.populate({ path: "user" }); + next(); +}); +WishlistSchema.pre(["find", "findOne"], function (next) { + this.populate({ path: "product", select: { questions: 0, comments: 0, adminComments: 0, variants: 0 } }); + next(); +}); +WishlistSchema.pre(["find", "findOne"], function (next) { + this.populate({ path: "variant", populate: ["shop", "warranty"] }); + next(); +}); + +const WishlistModel = model("Wishlist", WishlistSchema); +export { WishlistModel }; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..ab60f09 --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -0,0 +1,215 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPatch, httpPost, queryParam, request, requestBody } from "inversify-express-utils"; +import { HydratedDocument } from "mongoose"; + +import { ChangeEmailDTO, UpdateUserDTO, VerifyEmailDTO } from "./DTO/userUpdate.dto"; +import { IUser } from "./models/Abstraction/IUser"; +import { UserService } from "./user.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiBody, ApiFile, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { AuthMessage } from "../../common/enums/message.enum"; +import { OrderStatusQuery } from "../../common/types/query.type"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; +import { AddressService } from "../address/address.service"; +import { AuthService } from "../auth/auth.service"; +import { AuthDTO } from "../auth/DTO/Auth.dto"; +import { AuthCheckOtpDTO } from "../auth/DTO/AuthCheckOtp.dto"; +import { TokenDto } from "../auth/DTO/Token.dto"; +import { OrderService } from "../order/order.service"; + +@controller("/user") +@ApiTags("User") +class UserController extends BaseController { + @inject(IOCTYPES.UserService) userService: UserService; + @inject(IOCTYPES.AuthService) authService: AuthService; + @inject(IOCTYPES.OrderService) orderService: OrderService; + @inject(IOCTYPES.AddressService) addressService: AddressService; + + /** + * + * @param authDto + * @returns + */ + @ApiOperation("Authenticate to send OTP code") + @ApiResponse("Successful", 200, { message: AuthMessage.OtpSentToNo, phone: "09122569856" }) + @ApiResponse("BadRequest", 400, { details: ["فرمت موبایل اشتباه است"] }) + @ApiModel(AuthDTO) + @ApiBody("Authenticate with phone or email") + @httpPost("/authenticate", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(AuthDTO)) + async authUser(@requestBody() authDto: AuthDTO) { + const data = await this.authService.authenticate(authDto, "user"); + return this.response({ data }, HttpStatus.Ok); + } + /** + * + * @param loginOtpDto + */ + @ApiOperation("login user -> check sent OTP and generate the token") + @ApiResponse("Successful login", 200, { message: AuthMessage.SuccessLogin, Accesstoken: "tokenValue", refreshToken: "tokenValue" }) + @ApiModel(AuthCheckOtpDTO) + @ApiBody("login user with otp code") + @httpPost("/login/otp", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(AuthCheckOtpDTO)) + async loginOtp(@requestBody() loginOtpDto: AuthCheckOtpDTO) { + const data = await this.authService.loginOtpS(loginOtpDto, "user"); + return this.response({ data }, HttpStatus.Created); + } + + /** + * + * @param refreshToken + * @returns + */ + @ApiOperation("check the refresh token and generate access token base on that") + @ApiResponse("Successful", 200, { accessToken: "tokenValue", refreshToken: "tokenValue" }) + @ApiResponse("unauthorized", 401, { message: AuthMessage.TokenExpired }) + @ApiResponse("notFound", 404, { message: AuthMessage.TokenNotFound }) + @ApiModel(TokenDto) + @httpPost("/token", rateLimit(appConfig.rate), ValidationMiddleware.validateInput(TokenDto)) + public async refreshTokens(@requestBody() refreshToken: TokenDto) { + const data = await this.authService.refreshTokensS(refreshToken); + //return new tokens + return this.response({ data }, HttpStatus.Created); + } + /** + * + * @param req + * @returns + */ + @ApiOperation("logout the user based on the sent authorization token") + @ApiResponse("Successful", 200, { message: AuthMessage.LoggedOut }) + @ApiAuth() + @httpPost("/logout", rateLimit(appConfig.rate), Guard.authUser()) + public async logout(@request() req: Request) { + //this is maybe user or seller based on token + const user = req.user as IUser; + const data = await this.authService.logoutS(user._id.toString()); + //return logout message + return this.response({ data }); + } + + @ApiOperation("get current logged in user info ") + @ApiResponse("successful", 200) + @ApiAuth() + @httpGet("/profile", Guard.authUser()) + public async userProfile(@request() req: Request) { + const user = req.user as HydratedDocument; + const data = await this.userService.getProfile(user); + return this.response(data); + } + + @ApiOperation("update fields of the user with its current session") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(UpdateUserDTO) + @ApiAuth() + @httpPatch("/profile", Guard.authUser(), ValidationMiddleware.validateInput(UpdateUserDTO)) + async updateUserProfile(@requestBody() updateUserDto: UpdateUserDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.userService.updateUserProfileS(updateUserDto, user._id.toString()); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("change email of current session user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(ChangeEmailDTO) + @ApiAuth() + @httpPost("/profile/email/change", rateLimit(appConfig.rate), Guard.authUser(), ValidationMiddleware.validateInput(ChangeEmailDTO)) + public async changeEmail(@requestBody() changeEmailDto: ChangeEmailDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.authService.changeUserEmail(changeEmailDto, user._id.toString()); + return this.response(data); + } + + @ApiOperation("verify email of current session user") + @ApiResponse("successful", HttpStatus.Ok) + @ApiModel(VerifyEmailDTO) + @ApiAuth() + @httpPost("/profile/email/verify", rateLimit(appConfig.rate), Guard.authUser(), ValidationMiddleware.validateInput(VerifyEmailDTO)) + public async verifyUserEmail(@requestBody() verifyEmailDto: VerifyEmailDTO, @request() req: Request) { + const user = req.user as IUser; + const data = await this.authService.verifyUserEmail(verifyEmailDto, user._id.toString()); + return this.response(data); + } + + @ApiOperation("get current logged in user address ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/profile/address", Guard.authUser()) + public async getUserAddress(@request() req: Request) { + const user = req.user as IUser; + const data = await this.addressService.getUserAddress(user.address.toString()); + return this.response({ address: data }); + } + + @ApiOperation("get current logged in user comments ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/profile/comments", Guard.authUser()) + public async getUserComments(@request() req: Request) { + const user = req.user as IUser; + const data = await this.userService.getUserComments(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get current logged in user questions ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/profile/questions", Guard.authUser()) + public async getUserQuestions(@request() req: Request) { + const user = req.user as IUser; + const data = await this.userService.getUserQuestions(user._id.toString()); + return this.response(data); + } + + @ApiOperation("get current logged in user product wishlist ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/profile/wishlist", Guard.authUser()) + public async getUserWishList(@request() req: Request) { + const user = req.user as IUser; + const data = await this.userService.getUserWishList(user._id.toString()); + return this.response(data); + } + + //########################################### + //order + @ApiOperation("get current logged in user order ") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("status", "the status of order ==> can be = Delivered | Cancelled | Processing") + @ApiAuth() + @httpGet("/profile/orders", Guard.authUser()) + public async getUserOrders(@request() req: Request, @queryParam("status") status: string) { + const statusQuery = status as OrderStatusQuery; + const user = req.user as IUser; + const data = await this.orderService.getUserOrders(user._id.toString(), statusQuery); + return this.response(data); + } + + //uploader + @ApiOperation("Upload a user info image") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload", Guard.authUser(), UploadService.single("image", "user-profile")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { UserController }; diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts new file mode 100644 index 0000000..1914acb --- /dev/null +++ b/src/modules/user/user.repository.ts @@ -0,0 +1,35 @@ +import { IUser } from "./models/Abstraction/IUser"; +import { IWishlist } from "./models/Abstraction/IWishlist"; +import { UserModel } from "./models/user.model"; +import { WishlistModel } from "./models/wishlist.model"; +import { BaseRepository } from "../../common/base/repository"; + +class UserRepository extends BaseRepository { + constructor() { + super(UserModel); + } + + async findUserByPhone(phoneNumber: string) { + return this.model.findOne({ phoneNumber }); + } + + async findUserByEmail(email: string) { + return this.model.findOne({ email }); + } +} + +function createUserRepository(): UserRepository { + return new UserRepository(); +} + +class WishlistRepo extends BaseRepository { + constructor() { + super(WishlistModel); + } +} + +function createWishlistRepo(): WishlistRepo { + return new WishlistRepo(); +} + +export { UserRepository, createUserRepository, WishlistRepo, createWishlistRepo }; diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts new file mode 100644 index 0000000..77057bd --- /dev/null +++ b/src/modules/user/user.service.ts @@ -0,0 +1,85 @@ +import { inject, injectable } from "inversify"; +import { HydratedDocument, isValidObjectId } from "mongoose"; + +import { CommentDTO } from "./DTO/comment.dto"; +import { UpdateUserDTO } from "./DTO/userUpdate.dto"; +import { WishlistDTO } from "./DTO/wishlist.dto"; +import { UserRepository, WishlistRepo } from "./user.repository"; +import { CommonMessage, UserMessage } from "../../common/enums/message.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { AddressRepo } from "../address/address.repository"; +import { CommentRepository } from "../product/Repository/comment"; +import { QuestionRepository } from "../product/Repository/question"; +import { SellerRepository } from "../seller/seller.repository"; +import { IUser } from "./models/Abstraction/IUser"; +import { PaginationDTO } from "../../common/dto/pagination.dto"; +import { paginationUtils } from "../../utils/pagination.utils"; + +@injectable() +class UserService { + @inject(IOCTYPES.UserRepository) userRepo: UserRepository; + @inject(IOCTYPES.AddressRepo) addressRepo: AddressRepo; + @inject(IOCTYPES.CommentRepository) commentRepo: CommentRepository; + @inject(IOCTYPES.QuestionRepository) questionRepo: QuestionRepository; + @inject(IOCTYPES.WishlistRepo) wishlistRepo: WishlistRepo; + @inject(IOCTYPES.SellerRepository) sellerRepo: SellerRepository; + + async getAllUsers(queryDto: PaginationDTO) { + const { limit, skip } = paginationUtils(queryDto); + const count = await this.userRepo.model.countDocuments(); + const users = await this.userRepo.model.find().skip(skip).limit(limit).lean(); + return { users, count }; + } + + async getUserById(id: string) { + if (!isValidObjectId(id)) throw new BadRequestError(CommonMessage.NotValid); + const user = await this.userRepo.model.findById(id).lean(); + if (!user) throw new BadRequestError(UserMessage.UserNotFound); + return { user }; + } + + async getProfile(user: HydratedDocument) { + const address = await this.addressRepo.model.findOne({ _id: user.address }).lean(); + const isSeller = await this.sellerRepo.model.exists({ phoneNumber: user.phoneNumber }); + return { + info: { + ...user.toJSON(), + userAddress: address, + isSeller: !!isSeller, + }, + }; + } + + async updateUserProfileS(updateDto: UpdateUserDTO, id: string) { + const updatedUser = await this.userRepo.model.findByIdAndUpdate(id, updateDto, { new: true }); + + if (!updatedUser) throw new BadRequestError(CommonMessage.NotFound); + return { + message: UserMessage.UserUpdated, + updatedUser, + }; + } + + async getUserWishList(userId: string) { + const docs = await this.wishlistRepo.model.find({ user: userId }); + const wishlist = docs.map((doc) => WishlistDTO.transformWishlist(doc)); + return { wishlist }; + } + async getUserQuestions(userId: string) { + const docs = await this.questionRepo.model.find({ user: userId }); + return { docs }; + } + async getUserComments(userId: string) { + const docs = await this.commentRepo.model.find({ user: userId }); + const comments = docs.map((doc) => CommentDTO.transformComment(doc)); + + return { comments }; + } + + async getUsersCount() { + return this.userRepo.model.countDocuments(); + } +} + +export { UserService }; diff --git a/src/modules/wallet/DTO/withdraw.dto.ts b/src/modules/wallet/DTO/withdraw.dto.ts new file mode 100644 index 0000000..89d7a43 --- /dev/null +++ b/src/modules/wallet/DTO/withdraw.dto.ts @@ -0,0 +1,48 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsNumber, IsOptional, IsString, Min } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { WithdrawalStatus } from "../models/abstraction/IWithdrawal"; + +export class WithdrawDTO { + @Expose() + @IsNotEmpty({ message: "مقدار برداشت نمی‌تواند خالی باشد" }) + @IsNumber({}, { message: "مقدار برداشت باید یک عدد باشد" }) + @Min(100000, { message: "حداقل مقدار برداشت باید بیشتر از 100000 باشد" }) + @ApiProperty({ type: "number", description: "amount of the withdrawal", example: 100000 }) + amount: number; + + // @Expose() + // @IsNotEmpty({ message: "مقدار شماره شبا نمیتواند خالی باشد" }) + // @IsString() + // @ApiProperty({ type: "string", description: "shaba for withdrawal request" }) + // shaba: string; + + // @Expose() + // @IsNotEmpty({ message: "مقدار شماره حساب نمیتواند خالی باشد" }) + // @IsString() + // @ApiProperty({ type: "string", description: "bank account number for withdrawal request" }) + // bankAccountNumber: string; +} + +export class RejectWithdrawalDTO { + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "reject reason for withdrawal request" }) + rejectReason: string; +} + +export class WithdrawalStatusDTO { + @Expose() + @IsNotEmpty() + @IsString() + @ApiProperty({ type: "string", description: "status for withdrawal request", example: "completed | failed" }) + status: WithdrawalStatus; + + @Expose() + @IsOptional() + @IsString() + @ApiProperty({ type: "string", description: "reject reason for withdrawal request" }) + rejectReason: string; +} diff --git a/src/modules/wallet/constant/index.ts b/src/modules/wallet/constant/index.ts new file mode 100644 index 0000000..71069a3 --- /dev/null +++ b/src/modules/wallet/constant/index.ts @@ -0,0 +1,32 @@ +export const TransactionReason = Object.freeze({ + WITHDRAWAL: "برداشت پول", + ORDERـPAY: "پرداخت مبلغ سفارش", + BY_ADMIN: "شارژ توسط ادمین", +}); + +export const CSVHeaders = Object.freeze({ + fullName: "نام و نام خانوادگی", + phoneNumber: "شماره تماس", + email: "ایمیل", + accountStatus: "وضعیت حساب", + isRegisterCompleted: "ثبت نام تکمیل شده", + businessType: "نوع فعالیت", + isWholesaler: "عمده فروش", + createdAt: "تاریخ ثبت", + updatedAt: "تاریخ بروزرسانی", + amount: "مبلغ", + status: "وضعیت برداشت", + processedAt: "تاریخ پردازش", + IBAN: "شماره شبا", +}); + +export const Translate = Object.freeze({ + real: "حقیقی", + legal: "حقوقی", + Pending: "در انتظار", + Approved: "تایید شده", + Rejected: "رد شده", + Completed: "تکمیل شده", + Cancelled: "لغو شده", + Failed: "ناموفق", +}); diff --git a/src/modules/wallet/models/abstraction/ITransaction.ts b/src/modules/wallet/models/abstraction/ITransaction.ts new file mode 100644 index 0000000..80e9acf --- /dev/null +++ b/src/modules/wallet/models/abstraction/ITransaction.ts @@ -0,0 +1,15 @@ +import { Types } from "mongoose"; + +export enum TransactionType { + CREDIT = "CREDIT", + DEBIT = "DEBIT", + WITHDRAWAL = "WITHDRAWAL", +} + +export interface ITransaction { + wallet: Types.ObjectId; + order: number; + amount: number; + transactionType: TransactionType; + reason: string; +} diff --git a/src/modules/wallet/models/abstraction/IWallet.ts b/src/modules/wallet/models/abstraction/IWallet.ts new file mode 100644 index 0000000..98d15c2 --- /dev/null +++ b/src/modules/wallet/models/abstraction/IWallet.ts @@ -0,0 +1,6 @@ +import { Types } from "mongoose"; + +export interface IWallet { + seller: Types.ObjectId; + balance: number; +} diff --git a/src/modules/wallet/models/abstraction/IWithdrawal.ts b/src/modules/wallet/models/abstraction/IWithdrawal.ts new file mode 100644 index 0000000..5bf3d90 --- /dev/null +++ b/src/modules/wallet/models/abstraction/IWithdrawal.ts @@ -0,0 +1,17 @@ +import { Types } from "mongoose"; + +export enum WithdrawalStatus { + PENDING = "Pending", + COMPLETED = "Completed", + FAILED = "Failed", +} + +export interface IWithdrawal { + wallet: Types.ObjectId; + amount: number; + IBAN: string; + // bankAccountNumber: string; + status: WithdrawalStatus; + rejectReason: string; + processedAt: Date | null; +} diff --git a/src/modules/wallet/models/transaction.model.ts b/src/modules/wallet/models/transaction.model.ts new file mode 100644 index 0000000..4e7f04d --- /dev/null +++ b/src/modules/wallet/models/transaction.model.ts @@ -0,0 +1,18 @@ +import { Schema, model } from "mongoose"; + +import { ITransaction, TransactionType } from "./abstraction/ITransaction"; + +const TransactionSchema = new Schema( + { + wallet: { type: Schema.Types.ObjectId, ref: "Wallet", required: true }, + order: { type: Number, ref: "Order", default: null }, + amount: { type: Number, required: true }, + transactionType: { type: String, enum: TransactionType, required: true }, + reason: { type: String, required: false }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const TransactionModel = model("Transaction", TransactionSchema); + +export { TransactionModel }; diff --git a/src/modules/wallet/models/wallet.model.ts b/src/modules/wallet/models/wallet.model.ts new file mode 100644 index 0000000..14cf073 --- /dev/null +++ b/src/modules/wallet/models/wallet.model.ts @@ -0,0 +1,15 @@ +import { Schema, model } from "mongoose"; + +import { IWallet } from "./abstraction/IWallet"; + +const WalletSchema = new Schema( + { + seller: { type: Schema.Types.ObjectId, ref: "Seller", required: true, unique: true }, + balance: { type: Number, default: 0 }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const WalletModel = model("Wallet", WalletSchema); + +export { WalletModel }; diff --git a/src/modules/wallet/models/withdrawal.model.ts b/src/modules/wallet/models/withdrawal.model.ts new file mode 100644 index 0000000..9fcae00 --- /dev/null +++ b/src/modules/wallet/models/withdrawal.model.ts @@ -0,0 +1,20 @@ +import { Schema, model } from "mongoose"; + +import { IWithdrawal, WithdrawalStatus } from "./abstraction/IWithdrawal"; + +const WithdrawalSchema = new Schema( + { + wallet: { type: Schema.Types.ObjectId, ref: "Wallet", required: true }, + amount: { type: Number, required: true }, + IBAN: { type: String, required: true }, + // bankAccountNumber: { type: String, default: null }, + status: { type: String, enum: WithdrawalStatus, default: WithdrawalStatus.PENDING }, + rejectReason: { type: String, default: null }, + processedAt: { type: Date, default: null }, + }, + { timestamps: true, toJSON: { versionKey: false, virtuals: true }, id: false }, +); + +const WithdrawalModel = model("Withdrawal", WithdrawalSchema); + +export { IWithdrawal, WithdrawalModel }; diff --git a/src/modules/wallet/repository/transaction.repo.ts b/src/modules/wallet/repository/transaction.repo.ts new file mode 100644 index 0000000..2687225 --- /dev/null +++ b/src/modules/wallet/repository/transaction.repo.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { ITransaction } from "../models/abstraction/ITransaction"; +import { TransactionModel } from "../models/transaction.model"; + +class TransactionRepo extends BaseRepository { + constructor() { + super(TransactionModel); + } +} + +function createTransactionRepo(): TransactionRepo { + return new TransactionRepo(); +} + +export { TransactionRepo, createTransactionRepo }; diff --git a/src/modules/wallet/repository/wallet.repo.ts b/src/modules/wallet/repository/wallet.repo.ts new file mode 100644 index 0000000..c9f1072 --- /dev/null +++ b/src/modules/wallet/repository/wallet.repo.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { IWallet } from "../models/abstraction/IWallet"; +import { WalletModel } from "../models/wallet.model"; + +class WalletRepo extends BaseRepository { + constructor() { + super(WalletModel); + } +} + +function createWalletRepo(): WalletRepo { + return new WalletRepo(); +} + +export { WalletRepo, createWalletRepo }; diff --git a/src/modules/wallet/repository/withdrawal.repo.ts b/src/modules/wallet/repository/withdrawal.repo.ts new file mode 100644 index 0000000..da1da74 --- /dev/null +++ b/src/modules/wallet/repository/withdrawal.repo.ts @@ -0,0 +1,19 @@ +import { BaseRepository } from "../../../common/base/repository"; +import { WithdrawalStatus } from "../models/abstraction/IWithdrawal"; +import { IWithdrawal, WithdrawalModel } from "../models/withdrawal.model"; + +class WithdrawalRepo extends BaseRepository { + constructor() { + super(WithdrawalModel); + } + + async getWithdrawalRequestsReport() { + return this.model.countDocuments({ status: WithdrawalStatus.PENDING }); + } +} + +function createWithdrawalRepo(): WithdrawalRepo { + return new WithdrawalRepo(); +} + +export { WithdrawalRepo, createWithdrawalRepo }; diff --git a/src/modules/wallet/wallet.controller.ts b/src/modules/wallet/wallet.controller.ts new file mode 100644 index 0000000..4128597 --- /dev/null +++ b/src/modules/wallet/wallet.controller.ts @@ -0,0 +1,63 @@ +import { Request } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, request, requestBody } from "inversify-express-utils"; + +import { WalletService } from "./wallet.service"; +import { HttpStatus } from "../../common"; +import { WithdrawDTO } from "./DTO/withdraw.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; + +@controller("/wallet") +@ApiTags("Wallet") +class WalletController extends BaseController { + @inject(IOCTYPES.WalletService) walletService: WalletService; + + @ApiOperation("get all transaction of seller wallet ==> login as seller") + @ApiResponse("successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/transaction", Guard.authSeller()) + public async getTransactions(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.walletService.getTransactions(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("Get wallet balance ==> login as seller") + @ApiResponse("Successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/balance", Guard.authSeller()) + public async getWalletBalance(@request() req: Request) { + const seller = req.user as ISeller; + const balance = await this.walletService.getWalletBalance(seller._id.toString()); + return this.response(balance); + } + + @ApiOperation("Get all seller withdrawal requests ==> login as seller") + @ApiResponse("Successful", HttpStatus.Ok) + @ApiAuth() + @httpGet("/withdraw", Guard.authSeller()) + public async getWithdrawalRequests(@request() req: Request) { + const seller = req.user as ISeller; + const data = await this.walletService.getWithdrawalRequests(seller._id.toString()); + return this.response(data); + } + + @ApiOperation("withdraw request for seller wallet ==> login as seller") + @ApiResponse("Successful", HttpStatus.Created) + @ApiResponse("Insufficient balance or withdrawal failed", HttpStatus.BadRequest) + @ApiModel(WithdrawDTO) + @ApiAuth() + @httpPost("/withdraw", Guard.authSeller(), ValidationMiddleware.validateInput(WithdrawDTO)) + public async withdrawRequest(@request() req: Request, @requestBody() withdrawDto: WithdrawDTO) { + const seller = req.user as ISeller; + const data = await this.walletService.withdrawRequest(seller, withdrawDto.amount); + return this.response(data, HttpStatus.Created); + } +} + +export { WalletController }; diff --git a/src/modules/wallet/wallet.service.ts b/src/modules/wallet/wallet.service.ts new file mode 100644 index 0000000..74805bd --- /dev/null +++ b/src/modules/wallet/wallet.service.ts @@ -0,0 +1,431 @@ +import { stringify } from "csv"; +import { Response } from "express"; +import { inject, injectable } from "inversify"; +import { ClientSession, startSession } from "mongoose"; + +import { CSVHeaders, TransactionReason } from "./constant"; +import { TransactionType } from "./models/abstraction/ITransaction"; +import { WithdrawalStatus } from "./models/abstraction/IWithdrawal"; +import { TransactionRepo } from "./repository/transaction.repo"; +import { WalletRepo } from "./repository/wallet.repo"; +import { WithdrawalRepo } from "./repository/withdrawal.repo"; +import { SellerMessage, WalletMessage } from "../../common/enums/message.enum"; +import { BadRequestError, InternalError } from "../../core/app/app.errors"; +import { Logger } from "../../core/logging/logger"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { TimeService } from "../../utils/time.service"; +import { translate } from "../../utils/translate.utils"; +import { NotificationService } from "../notification/notification.service"; +import { WithdrawalStatusDTO } from "./DTO/withdraw.dto"; +import { BusinessTypeEnum } from "../seller/enum/seller.enum"; +import { ISeller } from "../seller/models/Abstraction/ISeller"; +import { LegalSellerRepo } from "../seller/repository/legalSeller.repository"; +import { RealSellerRepo } from "../seller/repository/realSeller.repository"; + +@injectable() +class WalletService { + private logger = new Logger(WalletService.name); + + @inject(IOCTYPES.WalletRepo) private walletRepo: WalletRepo; + @inject(IOCTYPES.NotificationService) notificationService: NotificationService; + @inject(IOCTYPES.TransactionRepo) private transactionRepo: TransactionRepo; + @inject(IOCTYPES.WithdrawalRepo) private withdrawalRepo: WithdrawalRepo; + @inject(IOCTYPES.RealSellerRepo) private realSellerRepo: RealSellerRepo; + @inject(IOCTYPES.LegalSellerRepo) private legalSellerRepo: LegalSellerRepo; + + //####################################### + //####################################### + async createSellerWallet(sellerId: string, session: ClientSession) { + const wallet = await this.walletRepo.model.create([{ seller: sellerId }], { session }); + return { wallet: wallet[0] }; + } + //####################################### + //####################################### + async creditSellerWallet(sellerId: string, amount: number) { + const session = await startSession(); + session.startTransaction(); + try { + const wallet = await this.walletRepo.model.findOne({ seller: sellerId }); + + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + //create a transaction for credit the wallet balance + const transaction = await this.transactionRepo.model.create( + [ + { + wallet: wallet._id, + amount, + transactionType: TransactionType.CREDIT, + reason: TransactionReason.BY_ADMIN, + }, + ], + { session }, + ); + + // update wallet balance + wallet.balance += amount; + await wallet.save({ session }); + await this.notificationService.notifyWalletCredit(sellerId, amount, session); + + await session.commitTransaction(); + + return { message: WalletMessage.CreditedSuccessfully, newBalance: wallet.balance, transaction }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################################### + //####################################### + async creditSellerWalletForOrder(sellerId: string, orderId: number, amount: number, session: ClientSession) { + const wallet = await this.walletRepo.model.findOneAndUpdate( + { seller: sellerId }, + { $inc: { balance: amount } }, + { session, new: true }, + ); + + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + // create a transaction for credit the wallet balance + const transaction = await this.transactionRepo.model.create( + [ + { + wallet: wallet._id, + order: orderId, + amount: amount, + transactionType: TransactionType.CREDIT, + reason: TransactionReason.ORDERـPAY, + }, + ], + { session }, + ); + + await this.notificationService.notifyWalletCredit(sellerId, amount, session); + + return { wallet, transaction }; + } + + //####################################### + //####################################### + async debitSellerWallet(sellerId: string, orderId: number, debitAmount: number, reason: string) { + const session = await startSession(); + session.startTransaction(); + try { + const wallet = await this.walletRepo.model.findOne({ seller: sellerId }); + + if (!wallet || wallet.balance < debitAmount) throw new BadRequestError(WalletMessage.InsufficientBalance); + + // create a new transaction for the debit + const transaction = await this.transactionRepo.model.create( + [ + { + wallet: wallet._id, + orderId, + amount: debitAmount, + transactionType: TransactionType.DEBIT, + reason, + }, + ], + { session }, + ); + + // update wallet balance + wallet.balance -= debitAmount; + + await wallet.save({ session }); + await this.notificationService.notifyWalletDebit(sellerId, debitAmount, session); + + await session.commitTransaction(); + + return { message: WalletMessage.DebitedSuccessfully, newBalance: wallet.balance, transaction }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################################### + //####################################### + async withdrawRequest(seller: ISeller, withdrawalAmount: number) { + const session = await startSession(); + session.startTransaction(); + try { + let IBAN: string = ""; + if (seller.businessType === BusinessTypeEnum.REAL) { + // + const realSeller = await this.realSellerRepo.model.findOne({ seller: seller._id }).lean(); + if (!realSeller) throw new BadRequestError(SellerMessage.SellerNotFound); + IBAN = realSeller.shebaNumber; + } else if (seller.businessType === BusinessTypeEnum.LEGAL) { + // + const legalSeller = await this.legalSellerRepo.model.findOne({ seller: seller._id }).lean(); + if (!legalSeller) throw new BadRequestError(SellerMessage.SellerNotFound); + IBAN = legalSeller.IBan; + } + + if (!IBAN) throw new BadRequestError(SellerMessage.InvalidBusinessType); + + const wallet = await this.walletRepo.model.findOne({ seller: seller._id.toString() }); + + if (!wallet || wallet.balance < withdrawalAmount) throw new BadRequestError(WalletMessage.InsufficientBalanceForWithdrawal); + + // create a new withdrawal request + const withdrawal = await this.withdrawalRepo.model.create( + [ + { + wallet: wallet._id, + amount: withdrawalAmount, + IBAN, + }, + ], + { session }, + ); + + await this.notificationService.notifyWithdrawalRequest(seller._id.toString(), withdrawalAmount, session); + + await session.commitTransaction(); + + return { message: WalletMessage.WithdrawalSubmitted, withdrawal: withdrawal[0] }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################################### + //####################################### + async processWithdrawal(withdrawalId: string, withdrawalStatusDto: WithdrawalStatusDTO) { + const session = await startSession(); + session.startTransaction(); + try { + const withdrawal = await this.withdrawalRepo.model.findOne({ _id: withdrawalId }); + if (!withdrawal) throw new BadRequestError(WalletMessage.WithdrawalNotFound); + + const wallet = await this.walletRepo.model.findOne({ _id: withdrawal.wallet }); + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + if (withdrawalStatusDto.status === WithdrawalStatus.COMPLETED) { + // check if the wallet has enough balance for the withdrawal + if (wallet.balance < withdrawal.amount) throw new BadRequestError(WalletMessage.InsufficientBalanceForWithdrawal); + + // update balance and withdrawal status + wallet.balance -= withdrawal.amount; + withdrawal.status = WithdrawalStatus.COMPLETED; + withdrawal.processedAt = new Date(); + // save the transaction as a debit + await this.transactionRepo.model.create( + [ + { + wallet: wallet._id, + orderId: null, + amount: withdrawal.amount, + transactionType: TransactionType.WITHDRAWAL, + reason: TransactionReason.WITHDRAWAL, + }, + ], + { session }, + ); + + await this.notificationService.notifyWithdrawalCompleted(wallet.seller.toString(), withdrawal.amount, session); + // + } else if (withdrawalStatusDto.status === WithdrawalStatus.FAILED) { + // update withdrawal status when its failed + withdrawal.status = WithdrawalStatus.FAILED; + withdrawal.processedAt = new Date(); + withdrawal.rejectReason = withdrawalStatusDto.rejectReason; + await this.notificationService.notifyWithdrawalFailed(wallet.seller.toString(), withdrawal.amount, session); + } + + await withdrawal.save({ session }); + await wallet.save({ session }); + await session.commitTransaction(); + return { message: `Withdrawal ${withdrawalStatusDto.status}`, newBalance: wallet.balance }; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + await session.endSession(); + } + } + + //####################################### + //####################################### + async getWalletBalance(sellerId: string) { + const wallet = await this.walletRepo.model.findOne({ seller: sellerId }); + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + return { balance: wallet.balance }; + } + + //####################################### + //####################################### + async getTransactions(sellerId: string) { + const wallet = await this.walletRepo.model.findOne({ seller: sellerId }); + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + const transaction = await this.transactionRepo.model.find({ wallet: wallet._id }); + + return { wallet, transaction }; + } + + //####################################### + //####################################### + async getWithdrawalRequests(sellerId: string) { + const wallet = await this.walletRepo.model.findOne({ seller: sellerId }); + if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + const withdrawals = await this.withdrawalRepo.model.find({ wallet: wallet._id }); + + return { wallet, withdrawals, lastPaidAmount: withdrawals[0]?.amount ?? null, lastPaidDate: withdrawals[0]?.processedAt || null }; + } + + async getWithdrawalRequestsReport() { + return await this.withdrawalRepo.getWithdrawalRequestsReport(); + } + + //*********** */ + + async getWithdrawalRequestsForAdminPanel() { + const wallets = await this.walletRepo.model.find().populate("seller"); + + const walletData = await Promise.all( + wallets.map(async (wallet) => { + const withdrawals = await this.withdrawalRepo.model.find({ wallet: wallet._id }).sort({ createdAt: -1 }); + + return { + wallet, + withdrawals, + lastPaidAmount: withdrawals[0]?.amount ?? null, + lastPaidDate: withdrawals[0]?.processedAt || null, + }; + }), + ); + + return { + walletData, + }; + } + //*********** */ + async getWithdrawalRequestsCsv(res: Response): Promise { + const withdrawals = await this.withdrawalRepo.model + .find({ status: WithdrawalStatus.PENDING }) + .populate({ path: "wallet", populate: { path: "seller" } }) + .sort({ createdAt: -1 }); + + const columns = [ + CSVHeaders.fullName, + CSVHeaders.phoneNumber, + CSVHeaders.email, + CSVHeaders.accountStatus, + CSVHeaders.businessType, + CSVHeaders.amount, + CSVHeaders.status, + // CSVHeaders.processedAt, + CSVHeaders.createdAt, + CSVHeaders.IBAN, + ]; + + // Create a CSV stringifier + const stringifier = stringify({ + header: true, + columns: columns, + }); + + // Write CSV data row by row + withdrawals.forEach((withdrawal: any) => { + stringifier.write({ + [CSVHeaders.fullName]: withdrawal.wallet.seller.fullName, + [CSVHeaders.phoneNumber]: withdrawal.wallet.seller.phoneNumber, + [CSVHeaders.email]: withdrawal.wallet.seller.email, + [CSVHeaders.accountStatus]: translate(withdrawal.wallet.seller.accountStatus), + [CSVHeaders.businessType]: translate(withdrawal.wallet.seller.businessType), + [CSVHeaders.IBAN]: withdrawal.IBAN, + [CSVHeaders.amount]: Intl.NumberFormat("en").format(withdrawal.amount * 10), + [CSVHeaders.status]: translate(withdrawal.status), + // [CSVHeaders.processedAt]: TimeService.convertGregorianToPersian(withdrawal.processedAt), + [CSVHeaders.createdAt]: TimeService.convertGregorianToPersian(withdrawal.createdAt), + }); + }); + + stringifier + .pipe(res) + .on("finish", () => { + this.logger.info("Withdrawal requests CSV generated successfully"); + }) + .on("error", (err) => { + this.logger.error("Error while generating withdrawal requests CSV", err); + throw new InternalError("something went wrong"); + }); + + stringifier.end(); + } + + //*********** */ + + // async approveWithdrawalRequest(withdrawalRequestId: string) { + // const session = await startSession(); + // session.startTransaction(); + // try { + // const withdrawRequest = await this.withdrawalRepo.model.findOne({ _id: withdrawalRequestId }); + // if (!withdrawRequest) throw new BadRequestError(WalletMessage.WithdrawalNotFound); + + // const wallet = await this.walletRepo.findById(withdrawRequest.wallet.toString()); + // if (!wallet) throw new BadRequestError(WalletMessage.WalletNotFound); + + // await this.withdrawalRepo.model.findByIdAndUpdate(withdrawRequest._id, { + // status: WithdrawalStatus.COMPLETED, + // processedAt: new Date(), + // }); + + // //TODO:Check this line + // wallet.balance -= withdrawRequest.amount; + + // const transaction = await this.transactionRepo.model.create( + // [ + // { + // wallet: wallet._id, + // amount: withdrawRequest.amount, + // transactionType: TransactionType.DEBIT, + // reason: TransactionReason.WITHDRAWAL, + // }, + // ], + // { session }, + // ); + // await wallet.save({ session }); + // await session.commitTransaction(); + + // return { + // message: WalletMessage.WithdrawalSubmitted, + // transaction: transaction[0], + // }; + // } catch (error) { + // await session.abortTransaction(); + // throw error; + // } finally { + // await session.endSession(); + // } + // } + + async rejectWithdrawalRequest(withdrawalRequestId: string, rejectReason: string) { + const withdrawRequest = await this.withdrawalRepo.model.findOne({ _id: withdrawalRequestId }); + if (!withdrawRequest) throw new BadRequestError(WalletMessage.WithdrawalNotFound); + + await this.withdrawalRepo.model.findByIdAndUpdate(withdrawRequest._id, { + status: WithdrawalStatus.FAILED, + processedAt: new Date(), + rejectReason, + }); + + return { + message: WalletMessage.RejectWithdrawalRequest, + }; + } +} + +export { WalletService }; diff --git a/src/modules/warranty/DTO/UpdateWarranty.dto.ts b/src/modules/warranty/DTO/UpdateWarranty.dto.ts new file mode 100644 index 0000000..1f34b81 --- /dev/null +++ b/src/modules/warranty/DTO/UpdateWarranty.dto.ts @@ -0,0 +1,25 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, IsOptional, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class UpdateWarrantyDTO { + @Expose() + @IsOptional() + @IsNotEmpty() + @Length(5, 40) + @ApiProperty({ type: "string", description: "name of the warranty", example: "گارانتی اصالت و سلامت فیزیکی" }) + name: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "duration of the warranty", example: "هفت روز" }) + duration: string; + + @Expose() + @IsOptional() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "logoUrl of the warranty", example: "https://cdnUrl.com" }) + logoUrl: string; +} diff --git a/src/modules/warranty/DTO/createWarranty.dto.ts b/src/modules/warranty/DTO/createWarranty.dto.ts new file mode 100644 index 0000000..9ce3df8 --- /dev/null +++ b/src/modules/warranty/DTO/createWarranty.dto.ts @@ -0,0 +1,22 @@ +import { Expose } from "class-transformer"; +import { IsNotEmpty, Length } from "class-validator"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + +export class CreateWarrantyDTO { + @Expose() + @IsNotEmpty() + @Length(5, 40) + @ApiProperty({ type: "string", description: "name of the warranty", example: "گارانتی اصالت و سلامت فیزیکی" }) + name: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "duration of the warranty", example: "هفت روز" }) + duration: string; + + @Expose() + @IsNotEmpty() + @ApiProperty({ type: "string", description: "logoUrl of the warranty", example: "https://cdnUrl.com" }) + logoUrl: string; +} diff --git a/src/modules/warranty/DTO/warranty.dto.ts b/src/modules/warranty/DTO/warranty.dto.ts new file mode 100644 index 0000000..ec1421f --- /dev/null +++ b/src/modules/warranty/DTO/warranty.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer"; + +export class WarrantyDTO { + @Expose() + _id: number; + + @Expose() + duration: string; + + @Expose() + logoUrl: string; + + @Expose() + name: string; +} diff --git a/src/modules/warranty/models/Abstraction/IWarranty.ts b/src/modules/warranty/models/Abstraction/IWarranty.ts new file mode 100644 index 0000000..3317fdf --- /dev/null +++ b/src/modules/warranty/models/Abstraction/IWarranty.ts @@ -0,0 +1,12 @@ +import { StatusEnum } from "../../../../common/enums/status.enum"; + +//TODO:update Warranty field to contain images + +export interface IWarranty { + _id: number; + name: string; + status: StatusEnum; + duration: string; + logoUrl: string; + deleted: boolean; +} diff --git a/src/modules/warranty/models/warranty.model.ts b/src/modules/warranty/models/warranty.model.ts new file mode 100644 index 0000000..c3775e9 --- /dev/null +++ b/src/modules/warranty/models/warranty.model.ts @@ -0,0 +1,24 @@ +import mongoose, { Schema, model } from "mongoose"; + +import { IWarranty } from "./Abstraction/IWarranty"; +import { StatusEnum } from "../../../common/enums/status.enum"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +const AutoIncrement = require("mongoose-sequence")(mongoose); + +const warrantySchema = new Schema( + { + _id: Number, + name: { type: String, unique: true, required: true }, + status: { type: String, enum: StatusEnum, default: StatusEnum.Pending }, + duration: { type: String, required: true }, + logoUrl: { type: String, required: true }, + deleted: { type: Boolean, default: false }, + }, + { timestamps: true, toJSON: { versionKey: false }, _id: false }, +); + +warrantySchema.plugin(AutoIncrement, { id: "warranty_id", inc_field: "_id", start_seq: 100 }); +const WarrantyModel = model("Warranty", warrantySchema); + +export { WarrantyModel }; diff --git a/src/modules/warranty/warranty.controller.ts b/src/modules/warranty/warranty.controller.ts new file mode 100644 index 0000000..4b59d7c --- /dev/null +++ b/src/modules/warranty/warranty.controller.ts @@ -0,0 +1,70 @@ +import { Request } from "express"; +import rateLimit from "express-rate-limit"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, queryParam, request, requestBody } from "inversify-express-utils"; + +import { CreateWarrantyDTO } from "./DTO/createWarranty.dto"; +import { WarrantyService } from "./warranty.service"; +import { HttpStatus } from "../../common"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiFile, ApiModel, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { appConfig } from "../../core/config/app.config"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { UploadService } from "../../utils/upload.service"; + +@controller("/warranty") +@ApiTags("Warranty") +class WarrantyController extends BaseController { + @inject(IOCTYPES.WarrantyService) warrantyService: WarrantyService; + + @ApiOperation("request to create a new warranty ==> login as seller") + @ApiResponse("successful", HttpStatus.Created) + @ApiModel(CreateWarrantyDTO) + @ApiAuth() + @httpPost("", rateLimit(appConfig.rate), Guard.authSeller(), ValidationMiddleware.validateInput(CreateWarrantyDTO)) + public async createWarranty(@requestBody() createWarrantyDto: CreateWarrantyDTO) { + const data = await this.warrantyService.createWarrantyS(createWarrantyDto); + return this.response({ data }, HttpStatus.Created); + } + + @ApiOperation("get a list of warranties") + @ApiResponse("successful", HttpStatus.Ok) + @ApiQuery("limit", "the limit of return data") + @ApiQuery("page", "the page want to get") + @ApiQuery("status", "the status of warranties ==> Approved | Pending") + @httpGet("") + public async getAll(@queryParam("limit") limit: string, @queryParam("page") page: string, @queryParam("status") status: string) { + const queries = { + limit: parseInt(limit), + page: parseInt(page), + status, + }; + const { count, warranties } = await this.warrantyService.getAllS(queries); + const { pager } = this.paginate(count); + return this.response({ warranties, pager }); + } + + //uploader + @ApiOperation("Upload a warranty info image ==> need to login as seller") + @ApiResponse("File uploaded successfully", HttpStatus.Accepted) + @ApiFile("image") + @ApiAuth() + @httpPost("/image/upload", Guard.authSeller(), UploadService.single("image", "warranty-images")) + public async uploadImage(@request() req: Request) { + // eslint-disable-next-line no-undef + const file = req.file as Express.MulterS3.File; + const data = { + message: "file uploaded!", + url: { + size: file?.size, + url: file?.location, + type: file?.mimetype, + }, + }; + return this.response({ data }, HttpStatus.Accepted); + } +} + +export { WarrantyController }; diff --git a/src/modules/warranty/warranty.repository.ts b/src/modules/warranty/warranty.repository.ts new file mode 100644 index 0000000..1343cc8 --- /dev/null +++ b/src/modules/warranty/warranty.repository.ts @@ -0,0 +1,38 @@ +import { IWarranty } from "./models/Abstraction/IWarranty"; +import { WarrantyModel } from "./models/warranty.model"; +import { BaseRepository } from "../../common/base/repository"; +import { StatusEnum } from "../../common/enums/status.enum"; + +class WarrantyRepository extends BaseRepository { + constructor() { + super(WarrantyModel); + } + + async findAllWarranties(queries: { limit: number; page: number; status: string }) { + const page = queries.page || 1; + const limit = queries.limit || 10; + const skip = (page - 1) * limit; + const query = { + ...(queries.status && { + status: queries.status, + }), + deleted: false, + }; + + const count = await this.model.countDocuments(query); + const docs = await this.model.find(query).skip(skip).limit(limit).sort({ createdAt: -1 }); + return { + docs, + count, + }; + } + + async pendingWarranties() { + return await this.model.countDocuments({ status: StatusEnum.Pending }); + } +} +function createWarrantyRepository(): WarrantyRepository { + return new WarrantyRepository(); +} + +export { WarrantyRepository, createWarrantyRepository }; diff --git a/src/modules/warranty/warranty.service.ts b/src/modules/warranty/warranty.service.ts new file mode 100644 index 0000000..17033a1 --- /dev/null +++ b/src/modules/warranty/warranty.service.ts @@ -0,0 +1,66 @@ +import { inject, injectable } from "inversify"; + +import { CreateWarrantyDTO } from "./DTO/createWarranty.dto"; +import { UpdateWarrantyDTO } from "./DTO/UpdateWarranty.dto"; +import { WarrantyRepository } from "./warranty.repository"; +import { CommonMessage, WarrantyMessage } from "../../common/enums/message.enum"; +import { StatusEnum } from "../../common/enums/status.enum"; +import { BadRequestError } from "../../core/app/app.errors"; +import { IOCTYPES } from "../../IOC/ioc.types"; + +@injectable() +class WarrantyService { + @inject(IOCTYPES.WarrantyRepository) warrantyRepo: WarrantyRepository; + + async createWarrantyS(createDto: CreateWarrantyDTO) { + const existWarr = await this.warrantyRepo.model.exists({ name: createDto.name }); + if (existWarr) throw new BadRequestError(WarrantyMessage.DuplicateName); + const newWarranty = await this.warrantyRepo.model.create(createDto); + return { + message: WarrantyMessage.Created, + newWarranty, + }; + } + + async getAllS(queries: { limit: number; page: number; status: string }) { + const { count, docs } = await this.warrantyRepo.findAllWarranties(queries); + + return { warranties: docs, count }; + } + + async deleteById(id: number) { + const deletedWarranty = await this.warrantyRepo.model.findByIdAndUpdate(id, { deleted: true }, { new: true }); + if (!deletedWarranty) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Deleted, + deletedWarranty, + }; + } + + async updateById(id: number, updateDto: UpdateWarrantyDTO) { + const updatedWarranty = await this.warrantyRepo.model.findByIdAndUpdate(id, updateDto, { new: true }); + if (!updatedWarranty) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedWarranty, + }; + } + + async approve(id: number) { + const updatedWarranty = await this.warrantyRepo.model.findByIdAndUpdate(id, { status: StatusEnum.Approved }, { new: true }); + if (!updatedWarranty) throw new BadRequestError(CommonMessage.NotValidId); + + return { + message: CommonMessage.Updated, + updatedWarranty, + }; + } + + async getPendingWarranties() { + return await this.warrantyRepo.pendingWarranties(); + } +} + +export { WarrantyService }; diff --git a/src/queues/constant.ts b/src/queues/constant.ts new file mode 100644 index 0000000..a638af1 --- /dev/null +++ b/src/queues/constant.ts @@ -0,0 +1,10 @@ +export const QUEUE_KEYS = Object.freeze({ + QUEUE_NAME: { + ORDER_QUEUE: "orderQueue", + }, + JOBS: { + CHECK_ORDER_STATUS: "checkOrderStatus", + CANCEL_ORDER: "cancelOrder", + SET_MAIN_STATUS: "setMainStatus", + }, +}); diff --git a/src/queues/index.ts b/src/queues/index.ts new file mode 100644 index 0000000..1e70ffe --- /dev/null +++ b/src/queues/index.ts @@ -0,0 +1,14 @@ +import { OrderProcessor } from "./order/OrderProcessor"; + +export const StartWorker = async () => { + await OrderProcessor.processQueue(); + + return [OrderProcessor.worker]; +}; + +// export const addJob = () => { +// console.log("job added"); + +// OrderQueue.addOrderToQueue(1, 1000); +// }; +// addJob(); diff --git a/src/queues/order/OrderProcessor.ts b/src/queues/order/OrderProcessor.ts new file mode 100644 index 0000000..26e3d6d --- /dev/null +++ b/src/queues/order/OrderProcessor.ts @@ -0,0 +1,176 @@ +import { Job, Worker } from "bullmq"; +import { startSession } from "mongoose"; + +import { OrderItemsStatus, OrdersStatus, PaymentStatus } from "../../common/enums/order.enum"; +import { Logger } from "../../core/logging/logger"; +import { connectRedis } from "../../db/connection"; +import { OrderModel } from "../../modules/order/models/order.model"; +import { OrderItemModel } from "../../modules/order/models/orderItem.model"; +import { CartPaymentModel } from "../../modules/payment/models/payments.model"; +import { ProductVariantModel } from "../../modules/product/models/productVariant.model"; +import { IUser } from "../../modules/user/models/Abstraction/IUser"; +import { SMS } from "../../utils/sms.service"; +import { QUEUE_KEYS } from "../constant"; +import { OrderQueue } from "./OrderQueue"; + +class OrderProcessor { + private static instance: OrderProcessor; + public worker: Worker; + private logger = new Logger(); + private constructor() {} + + public static getInstance(): OrderProcessor { + if (!OrderProcessor.instance) { + OrderProcessor.instance = new OrderProcessor(); + } + return OrderProcessor.instance; + } + + async processQueue() { + this.worker = new Worker( + QUEUE_KEYS.QUEUE_NAME.ORDER_QUEUE, + async (job: Job) => { + const { orderId } = job.data; + switch (job.name) { + case QUEUE_KEYS.JOBS.CHECK_ORDER_STATUS: + await this.checkAndNotify(orderId); + break; + case QUEUE_KEYS.JOBS.CANCEL_ORDER: + await this.cancelPendingOrder(orderId); + break; + case QUEUE_KEYS.JOBS.SET_MAIN_STATUS: + await this.setMainStatus(orderId); + break; + } + }, + { connection: connectRedis() }, + ); + + this.worker.on("completed", (job) => { + this.logger.info(`${job.id} has completed!`); + }); + + this.worker.on("failed", (job, err) => { + this.logger.error(`${job?.id} has failed with ${err.message}`, err); + }); + + this.worker.on("error", (err) => { + this.logger.error(`Worker error: ${err.message}`, err); + }); + } + + private async setMainStatus(orderId: number) { + try { + this.logger.info(`Checking status for order ID: ${orderId}`); + const order = await OrderModel.findById(orderId); + if (!order) throw new Error("Order not found"); + + const orderItems = await OrderItemModel.find({ order: order._id }); + const allDelivered = orderItems.every((item) => item.status === OrderItemsStatus.Delivered); + const allCancelled = orderItems.every((item) => + [OrderItemsStatus.cancelled_shop, OrderItemsStatus.cancelled_user, OrderItemsStatus.cancelled_system].includes(item.status), + ); + + if (allDelivered) { + await OrderModel.findByIdAndUpdate(orderId, { orderStatus: OrdersStatus.Delivered }); + } else if (allCancelled) { + await OrderModel.findByIdAndUpdate(orderId, { orderStatus: OrdersStatus.Cancelled }); + } + } catch (error) { + this.logger.error("Error updating order status:", error); + throw error; + } + } + + private async checkAndNotify(orderId: number) { + try { + this.logger.info(`Checking status for order ID: ${orderId}`); + const order = await OrderModel.findById(orderId); + if (!order) { + this.logger.warn(`Order ${orderId} not found in database`); + throw new Error("Order not found"); + } + + const user = order.user as unknown as IUser; + this.logger.info("User phone is:", user.phoneNumber); + + if (order.orderStatus === OrdersStatus.wait_payment) { + await SMS.sendOrderPendingSms(user.phoneNumber ?? order.shipmentAddress.phone, user.fullName, order._id); + this.logger.info(`Order ${orderId} is still pending. SMS sent to user.`); + await OrderQueue.addOrderToCancelQueue(orderId); + } + } catch (error) { + this.logger.error(`Error in checkAndNotify for order ${orderId}:`, error); + throw error; + } + } + + private async cancelPendingOrder(orderId: number) { + const session = await startSession(); + session.startTransaction(); + try { + this.logger.info(`Attempting to cancel order ID: ${orderId}`); + const order = await OrderModel.findById(orderId).session(session); + if (!order) { + this.logger.info(`Order ${orderId} not found.`); + throw new Error("Order not found"); + } + + const payment = await CartPaymentModel.findById(order.payment).session(session); + if (!payment) { + this.logger.info(`Payment of order ${orderId} not found.`); + throw new Error("Payment not found"); + } + + const orderItems = await OrderItemModel.find({ order: order._id }); + + if (order.orderStatus === OrdersStatus.wait_payment) { + order.orderStatus = OrdersStatus.cancelled_system; + payment.paymentStatus = PaymentStatus.Cancelled; + + await OrderItemModel.updateMany({ order: order._id }, { status: OrderItemsStatus.cancelled_system }, { session }); + + const items = orderItems.flatMap((sellerItem) => + sellerItem.shipmentItems.map((shipmentItem) => ({ + variantId: shipmentItem.variant.toString(), + quantity: shipmentItem.quantity, + })), + ); + + const bulkOperations = items.map(({ variantId, quantity }) => ({ + updateOne: { + filter: { _id: variantId }, + update: { $inc: { stock: quantity } }, + }, + })); + + if (bulkOperations.length > 0) { + await ProductVariantModel.bulkWrite(bulkOperations, { session }); + } + + await order.save({ session }); + await payment.save({ session }); + + this.logger.info(`Order ${orderId} cancelled due to no payment after 20 minutes.`); + } + + await session.commitTransaction(); + } catch (error) { + await session.abortTransaction(); + this.logger.error(`Error in cancelPendingOrder for order ${orderId}:`, error); + throw error; + } finally { + await session.endSession(); + } + } + + async closeWorker() { + if (this.worker) { + await this.worker.close(); + this.logger.warn("Worker closed successfully."); + } + } +} + +const instance = OrderProcessor.getInstance(); +export { instance as OrderProcessor }; diff --git a/src/queues/order/OrderQueue.ts b/src/queues/order/OrderQueue.ts new file mode 100644 index 0000000..4adc19c --- /dev/null +++ b/src/queues/order/OrderQueue.ts @@ -0,0 +1,50 @@ +import { JobsOptions, Queue } from "bullmq"; + +import { connectRedis } from "../../db/connection"; +import { QUEUE_KEYS } from "../constant"; + +class OrderQueue { + private DEFAULT_REMOVE_CONFIG: JobsOptions = { + removeOnComplete: { + age: 3600, + }, + removeOnFail: { + age: 2 * 3600, + }, + // attempts: 3, + // backoff: { + // type: "exponential", + // delay: 2 * 60 * 1000, + // }, + }; + private static instance: OrderQueue; + private queue: Queue; + + private constructor() { + this.queue = new Queue(QUEUE_KEYS.QUEUE_NAME.ORDER_QUEUE, { + connection: connectRedis(), + }); + } + + public static getInstance(): OrderQueue { + if (!OrderQueue.instance) { + OrderQueue.instance = new OrderQueue(); + } + return OrderQueue.instance; + } + + async addOrderToQueue(orderId: number, delay: number = 10 * 60 * 1000) { + await this.queue.add(QUEUE_KEYS.JOBS.CHECK_ORDER_STATUS, { orderId }, { delay, ...this.DEFAULT_REMOVE_CONFIG }); + } + + async addOrderToCheckStatus(orderId: number, delay: number = 5 * 1000) { + await this.queue.add(QUEUE_KEYS.JOBS.SET_MAIN_STATUS, { orderId }, { delay, ...this.DEFAULT_REMOVE_CONFIG }); + } + + async addOrderToCancelQueue(orderId: number, delay: number = 10 * 60 * 1000) { + await this.queue.add(QUEUE_KEYS.JOBS.CANCEL_ORDER, { orderId }, { delay, ...this.DEFAULT_REMOVE_CONFIG }); + } +} + +const instance = OrderQueue.getInstance(); +export { instance as OrderQueue }; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..e0b438d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,56 @@ +import "reflect-metadata"; +import { createServer } from "http"; + +import { config } from "dotenv"; +import { Container } from "inversify"; + +config(); + +import { App } from "./app"; +import { Logger } from "./core/logging/logger"; +import { connectMongo } from "./db/connection"; +import { IOCTYPES } from "./IOC/ioc.types"; +import { ChatGateway } from "./modules/chat/chat.gateway"; +import { StartWorker } from "./queues"; +import { gracefulShutdown } from "./utils/shutdown.utils"; + +const PORT = parseInt(process.env.PORT) || 4000; +const logger = new Logger(); +async function bootStrap() { + try { + await connectMongo(); + logger.info("mongodb connected"); + // + const container = new Container(); + const app = new App(container).Build(); + const server = createServer(app); + // + const chatGateway = container.get(IOCTYPES.ChatGateway); + const wsServer = chatGateway.initialize(server); + + const worker = await StartWorker(); + + // + server.listen(PORT, "0.0.0.0", () => { + logger.info(`listening on http://localhost:${PORT}`); + logger.info(`swagger documentation is serving on http://localhost:${PORT}/api-docs`); + }); + + process.on("SIGINT", () => gracefulShutdown("SIGINT", server, wsServer, worker[0], logger)); + + process.on("SIGTERM", () => gracefulShutdown("SIGTERM", server, wsServer, worker[0], logger)); + } catch (error) { + logger.error("Error starting the server.", error); + process.exit(1); + } +} + +bootStrap(); + +process.on("uncaughtException", function (err) { + logger.error("Uncaught exception", err); +}); + +process.on("unhandledRejection", (reason, promise) => { + logger.error("Unhandled Rejection at: Promise", { promise, reason }); +}); diff --git a/src/template/PRPayment.ts b/src/template/PRPayment.ts new file mode 100644 index 0000000..b1c6488 --- /dev/null +++ b/src/template/PRPayment.ts @@ -0,0 +1,131 @@ +import Handlebars from "handlebars"; + +// Register a custom helper for comparison +Handlebars.registerHelper("ifEquals", function (this: any, arg1, arg2, options) { + return arg1 === arg2 ? options.fn(this) : options.inverse(this); +}); + +const verifyPaymentHtml = ` + + + + + در حال بازگشت به فروشگاه + + + +
+
+

در حال بازگشت به پنل ...

+ {{#ifEquals status "OK"}} +
+

پرداخت موفق

+ {{else}} +
+

پرداخت ناموفق

+ {{/ifEquals}} +

شماره سفارش: {{request_id}}

+

شناسه پرداخت: {{payment_id}}

+
+ + + +`; + +const verifyPRPaymentTemplate = Handlebars.compile(verifyPaymentHtml); +export { verifyPRPaymentTemplate }; diff --git a/src/template/contract.ts b/src/template/contract.ts new file mode 100644 index 0000000..df2bdb2 --- /dev/null +++ b/src/template/contract.ts @@ -0,0 +1,115 @@ +import Handlebars from "handlebars"; + +const contractHtml = ` +
+

+ + مقدمه + +

+

+ + + + شرکت شینان در راستای رسالت خود در جهت حمایت از کسب و کارهای کوچک، توسعه کارآفرینی و بهبود فضای فروش اینترنتی و + رقابتی، در تاریخ + + + {{date}} + + + مطابق مواد 10 و 219 قانون مدنی، بر اساس تعامل مشترک و حسن نیت و مفاد ذیل اقدام به انعقاد قرارداد می‌نماید. + + + +

+

+ + ماده 1. طرفین قرارداد + +

+

+ + شینان + + به شماره ثبت 0000 و شناسه ملی 00000000 و نشانی اینترنتی + + + www.shinan.ir + + + +

+

+ + طرف دوم: + +

+

+ + آقا/خانم: {{seller_name}} + +

+

+ + شماره شناسنامه: {{id_number}} + +

+

+ + کد ملی: {{national_code}} + +

+

+ + به نشانی: {{address}} + +

+

+ + کدپستی: {{postal_code}} + +

+

+ + شماره تماس: {{phone_number}} + +

+

+ + نشانی الکترونیکی: {{email}} + +

+

+ + + که از این پس در این قرارداد «فروشنده» نامیده می‌شود. + + +

+ +
{{content}}
+ +

+ + اینجانب {{seller_name}} با پذیرفتن این قرارداد در پنل فروشندگان اقرار می‌نمایم که مدارک بارگزاری شده در پنل فروشندگان (کارت ملی و + مدارک دیگر) متعلق به اینجانب می‌باشد و تمام مسئولیت صحت و یا عدم صحت آن را بر عهده می‌گیرم. پذیرش قرارداد به منزله امضای قرارداد + می‌باشد. + +

+

+ مهر و امضا شینان : +

+
+`; + +const contractTemplate = Handlebars.compile(contractHtml); +export { contractTemplate }; diff --git a/src/template/payment.ts b/src/template/payment.ts new file mode 100644 index 0000000..5b30539 --- /dev/null +++ b/src/template/payment.ts @@ -0,0 +1,131 @@ +import Handlebars from "handlebars"; + +// Register a custom helper for comparison +Handlebars.registerHelper("ifEquals", function (this: any, arg1, arg2, options) { + return arg1 === arg2 ? options.fn(this) : options.inverse(this); +}); + +const verifyPaymentHtml = ` + + + + + در حال بازگشت به فروشگاه + + + +
+
+

در حال بازگشت به فروشگاه ...

+ {{#ifEquals status "OK"}} +
+

پرداخت موفق

+ {{else}} +
+

پرداخت ناموفق

+ {{/ifEquals}} +

شماره سفارش: {{order_id}}

+

شناسه پرداخت: {{payment_id}}

+
+ + + +`; + +const verifyPaymentTemplate = Handlebars.compile(verifyPaymentHtml); +export { verifyPaymentTemplate }; diff --git a/src/utils/cache.service.ts b/src/utils/cache.service.ts new file mode 100644 index 0000000..40c6ff0 --- /dev/null +++ b/src/utils/cache.service.ts @@ -0,0 +1,196 @@ +import { inject, injectable } from "inversify"; + +import { RedisService } from "./redis.service"; +import { IOCTYPES } from "../IOC/ioc.types"; + +@injectable() +export class CacheService { + constructor(@inject(IOCTYPES.RedisService) private redisService: RedisService) {} + + /** + * Set a key-value pair with TTL (in seconds) + * Provides synchronous interface for backward compatibility + */ + set(key: string, value: string, ttlSeconds: number): void { + // Fire and forget - non-blocking operation for backward compatibility + this.setAsync(key, value, ttlSeconds).catch((error) => { + console.error(`Failed to set cache key ${key}:`, error); + }); + } + + /** + * Set a key-value pair with TTL (async version) + */ + async setAsync(key: string, value: string, ttlSeconds: number): Promise { + try { + await this.redisService.set(key, value, ttlSeconds); + } catch (error) { + console.error(`Failed to set cache key ${key}:`, error); + throw error; + } + } + + /** + * Get value by key (synchronous interface for compatibility) + * Note: This method is deprecated and should be replaced with getAsync + */ + get(_key: string): string | undefined { + // For backward compatibility, return undefined and log warning + console.warn("CacheService.get() is deprecated. Use getAsync() instead for Redis-based caching."); + return undefined; + } + + /** + * Get value by key (async version) + */ + async getAsync(key: string): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, returning null for key:", key); + return null; + } + return await this.redisService.get(key); + } catch (error) { + console.error(`Failed to get cache key ${key}:`, error); + return null; + } + } + + /** + * Delete a key (synchronous interface for compatibility) + */ + del(key: string): void { + // Fire and forget - non-blocking operation for backward compatibility + this.delAsync(key).catch((error) => { + console.error(`Failed to delete cache key ${key}:`, error); + }); + } + + /** + * Delete a key (async version) + */ + async delAsync(key: string): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, cannot delete key:", key); + return false; + } + const result = await this.redisService.del(key); + return result > 0; + } catch (error) { + console.error(`Failed to delete cache key ${key}:`, error); + return false; + } + } + + /** + * Get TTL of a key in milliseconds (synchronous interface for compatibility) + * Note: This method is deprecated and should be replaced with getTtlAsync + */ + getTtl(_key: string): number | undefined { + // For backward compatibility, return undefined and log warning + console.warn("CacheService.getTtl() is deprecated. Use getTtlAsync() instead for Redis-based caching."); + return undefined; + } + + /** + * Get TTL of a key in milliseconds (async version) + */ + async getTtlAsync(key: string): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, returning null TTL for key:", key); + return null; + } + + const ttlSeconds = await this.redisService.ttl(key); + if (ttlSeconds === -1) return null; // Key exists but has no expiration + if (ttlSeconds === -2) return null; // Key doesn't exist + return ttlSeconds * 1000; // Convert to milliseconds for compatibility + } catch (error) { + console.error(`Failed to get TTL for cache key ${key}:`, error); + return null; + } + } + + /** + * Check if a key exists + */ + async exists(key: string): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, returning false for exists check:", key); + return false; + } + return await this.redisService.exists(key); + } catch (error) { + console.error(`Failed to check existence of cache key ${key}:`, error); + return false; + } + } + + /** + * Set TTL for an existing key (in seconds) + */ + async expire(key: string, ttlSeconds: number): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, cannot set expiration for key:", key); + return false; + } + return await this.redisService.expire(key, ttlSeconds); + } catch (error) { + console.error(`Failed to set expiration for cache key ${key}:`, error); + return false; + } + } + + /** + * Get all keys matching a pattern + */ + async keys(pattern: string = "*"): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, returning empty array for keys pattern:", pattern); + return []; + } + return await this.redisService.keys(pattern); + } catch (error) { + console.error(`Failed to get keys with pattern ${pattern}:`, error); + return []; + } + } + + /** + * Clear all cache entries + */ + async flushAll(): Promise { + try { + if (!this.redisService.isRedisConnected()) { + console.warn("Redis not connected, cannot flush cache"); + return; + } + await this.redisService.flushdb(); + } catch (error) { + console.error("Failed to flush cache:", error); + } + } + + /** + * Check if cache is available (Redis connected) + */ + isAvailable(): boolean { + return this.redisService.isRedisConnected(); + } + + /** + * Get Redis client for advanced operations + */ + getRedisClient() { + return this.redisService.getClient(); + } +} + +export function createCacheService(redisService: RedisService): CacheService { + return new CacheService(redisService); +} diff --git a/src/utils/email.service.ts b/src/utils/email.service.ts new file mode 100644 index 0000000..9abe861 --- /dev/null +++ b/src/utils/email.service.ts @@ -0,0 +1,128 @@ +import nodemailer from "nodemailer"; + +import { OrderItemsStatus } from "../common/enums/order.enum"; +import { InternalError } from "../core/app/app.errors"; + +type EmailOptions = { + to: string; + subject: string; + text?: string; + html?: string; +}; + +type OrderDetails = { + orderId: number; + trackingInfo: Array<{ + trackCode?: string; + status: OrderItemsStatus; + shipper: string; + postingDate?: string; + }>; +}; +const VERIFY_EMAIL_SUBJECT = "تایید ایمیل"; +const TRACK_ORDER_EMAIL_SUBJECT = "وضعیت سفارش"; +const VERIFY_EMAIL_TEXT = "کد تایید ایمیل :\n"; +export class EmailService { + private static transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTPS_PORT), + secure: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, + }); + + public static async sendEmail(options: EmailOptions) { + try { + const mailOptions = { + from: `"shinan" <${process.env.SMTP_EMAIL}>`, + to: options.to, + subject: options.subject, + text: options.text, + html: options.html, + }; + + await this.transporter.sendMail(mailOptions); + + console.log(`email sent successfully to ${options.to}`); + } catch (error) { + console.error(`failed to send email to ${options.to}:`, error); + throw new InternalError(["Failed to send email"]); + } + } + + public static async sendVerifyEmail(to: string, otp: string) { + await EmailService.sendEmail({ subject: VERIFY_EMAIL_SUBJECT, to, text: `${VERIFY_EMAIL_TEXT} ${otp}` }); + } + + public static async sendOrderTrackingEmail(userEmail: string, orderDetails: OrderDetails) { + const emailContent = ` + +
+
+

جزئیات پیگیری سفارش

+
+
+

مشتری گرامی،

+

+ جزئیات پیگیری سفارش شما با شماره ${orderDetails.orderId} به شرح زیر است: +

+ + + + + + + + + + + ${orderDetails.trackingInfo + .map( + (info) => ` + + + + + + + `, + ) + .join("")} + +
کد پیگیریوضعیت + شرکت حمل و نقل + + تاریخ ارسال +
${info.trackCode || "ناموجود"}${info.status || "ناموجود"}${info.shipper || "ناموجود"}${info.postingDate || "ناموجود"}
+
+
+

از خرید شما سپاسگزاریم!

+
+
+ + + `; + + await EmailService.sendEmail({ + subject: TRACK_ORDER_EMAIL_SUBJECT, + to: userEmail, + text: undefined, + html: emailContent, + }); + } + + public static async verifyConnection() { + try { + await this.transporter.verify(); + console.log("SMTP connection verified successfully."); + } catch (error) { + console.error("SMTP connection verification failed:", error); + throw new Error("SMTP connection verification failed"); + } + } +} diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..ac99b89 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,29 @@ +export function omit, K extends keyof never>(obj: T, keys: K[]): Omit { + // I'm sure this could be done in a better way, + // but if we don't do this we run into issues with + // the interaction between Object.entries() and + // Set. + const omitKeys = new Set(keys as string[]); + + return Object.fromEntries(Object.entries(obj).filter(([k]) => !omitKeys.has(k))) as Omit; +} + +export const priceNumberFormat = (price: number) => Intl.NumberFormat("fa-IR").format(price); + +// const omitKey = [ +// "learningStatus", +// "contractStatus", +// "registerStatus", +// "documentStatus", +// "paymentStatus", +// "rate", +// "prodcuts", +// "purchases", +// "orders", +// ]; +// const payload = omit(updateDto, omitKey); +// console.log(payload); + +// const omitKey = ["basket", "favoriteProducts", "comments", "orders", "phoneNumber", "email"]; +// const payload = omit(updateDto, omitKey); +// console.log(payload); diff --git a/src/utils/pagination.utils.ts b/src/utils/pagination.utils.ts new file mode 100644 index 0000000..63051c7 --- /dev/null +++ b/src/utils/pagination.utils.ts @@ -0,0 +1,11 @@ +import { PaginationDTO } from "../common/dto/pagination.dto"; + +export function paginationUtils(paginationData: PaginationDTO) { + const { limit = 10, page = 1 } = paginationData; + const skip = (page - 1) * limit; + + return { + skip, + limit, + }; +} diff --git a/src/utils/passport.service.ts b/src/utils/passport.service.ts new file mode 100644 index 0000000..2418b73 --- /dev/null +++ b/src/utils/passport.service.ts @@ -0,0 +1,115 @@ +import { inject, injectable } from "inversify"; +import passport from "passport"; +import { ExtractJwt, Strategy as JwtStrategy, StrategyOptionsWithoutRequest, VerifiedCallback } from "passport-jwt"; + +import { AuthAdminToken, AuthTokenPayload } from "../common/types/jwt.type"; +import { Logger } from "../core/logging/logger"; +import { IOCTYPES } from "../IOC/ioc.types"; +import { AdminRepository } from "../modules/admin/repository/admin"; +import { SellerRepository } from "../modules/seller/seller.repository"; +import { UserRepository } from "../modules/user/user.repository"; + +@injectable() +class PassportAuth { + private logger = new Logger(PassportAuth.name); + @inject(IOCTYPES.UserRepository) userRepository: UserRepository; + @inject(IOCTYPES.SellerRepository) sellerRepository: SellerRepository; + @inject(IOCTYPES.AdminRepository) adminRepository: AdminRepository; + + private jwtOptions: StrategyOptionsWithoutRequest = { + secretOrKey: process.env.JWT_SECRET || "", + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + }; + + public initialize = () => { + return passport.initialize(); + }; + + public UserJwt = async (payload: AuthTokenPayload, done: VerifiedCallback) => { + try { + const { sub } = payload; + + const userInfo = await this.userRepository.findById(sub); + + if (!userInfo) { + return done(null, false); + } + done(null, userInfo); + } catch (error) { + this.logger.error("error in UserJwt verify", error); + done(error, false); + } + }; + public AdminJwt = async (payload: AuthAdminToken, done: VerifiedCallback) => { + try { + const { sub } = payload; + + const admin = await this.adminRepository.model.findOne({ _id: sub }).populate([ + { + path: "permissions", + }, + { + path: "role", + }, + ]); + + if (!admin) { + return done(null, false); + } + console.log({ admin }); + done(null, admin); + } catch (error) { + this.logger.error("error in AdminJwt verify", error); + done(error, false); + } + }; + public SellerJwt = async (payload: AuthTokenPayload, done: VerifiedCallback) => { + try { + const { sub } = payload; + + const seller = await this.sellerRepository.findById(sub); + + if (!seller) { + return done(null, false); + } + done(null, seller); + } catch (error) { + this.logger.error("error in SellerJwt verify", error); + done(error, false); + } + }; + + public sellerOrAdmin = async (payload: AuthTokenPayload, done: VerifiedCallback) => { + try { + const { sub } = payload; + + const seller = await this.sellerRepository.findById(sub); + + if (!seller) { + const admin = await this.adminRepository.model.findById(sub).populate({ + path: "role", + populate: "permissions", + }); + + if (!admin) return done(null, false); + + return done(null, admin); + } + + done(null, seller); + } catch (error) { + this.logger.error("error in SellerJwt verify", error); + done(error, false); + } + }; + + public plug = (): void => { + passport.use("UserJwt", new JwtStrategy(this.jwtOptions, this.UserJwt)); + passport.use("AdminJwt", new JwtStrategy(this.jwtOptions, this.AdminJwt)); + passport.use("SellerJwt", new JwtStrategy(this.jwtOptions, this.SellerJwt)); + passport.use("SellerOrAdmin", new JwtStrategy(this.jwtOptions, this.sellerOrAdmin)); + passport.use("OptionalUserJwt", new JwtStrategy(this.jwtOptions, this.UserJwt)); + }; +} + +export { PassportAuth }; diff --git a/src/utils/redis.service.ts b/src/utils/redis.service.ts new file mode 100644 index 0000000..a591fca --- /dev/null +++ b/src/utils/redis.service.ts @@ -0,0 +1,177 @@ +import { injectable } from "inversify"; +import Redis from "ioredis"; + +import { Logger } from "../core/logging/logger"; + +@injectable() +export class RedisService { + private readonly logger = new Logger(); + + private client: Redis; + private isConnected = false; + + constructor() { + this.client = new Redis({ + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, + db: parseInt(process.env.REDIS_DB || "0"), + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + + this.setupEventListeners(); + this.connect(); + } + + private setupEventListeners(): void { + this.client.on("error", (error) => { + this.logger.error("Redis connection error:", error); + this.isConnected = false; + }); + + this.client.on("connect", () => { + this.logger.info("Connected to Redis"); + this.isConnected = true; + }); + + this.client.on("ready", () => { + this.logger.info("Redis client ready"); + this.isConnected = true; + }); + + this.client.on("close", () => { + this.logger.info("Redis connection closed"); + this.isConnected = false; + }); + } + + private async connect(): Promise { + try { + await this.client.connect(); + } catch (error) { + this.logger.error("Failed to connect to Redis:", error); + // Continue without Redis - methods will handle gracefully + } + } + + /** + * Set a key-value pair with optional TTL (in seconds) + */ + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + if (ttlSeconds) { + await this.client.setex(key, ttlSeconds, value); + } else { + await this.client.set(key, value); + } + } + + /** + * Get value by key + */ + async get(key: string): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + return await this.client.get(key); + } + + /** + * Delete a key + */ + async del(key: string): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + return await this.client.del(key); + } + + /** + * Check if a key exists + */ + async exists(key: string): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + const result = await this.client.exists(key); + return result === 1; + } + + /** + * Get TTL of a key (in seconds) + */ + async ttl(key: string): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + return await this.client.ttl(key); + } + + /** + * Set TTL for an existing key (in seconds) + */ + async expire(key: string, ttlSeconds: number): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + const result = await this.client.expire(key, ttlSeconds); + return result === 1; + } + + /** + * Get all keys matching a pattern + */ + async keys(pattern: string): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + return await this.client.keys(pattern); + } + + /** + * Flush all keys in the current database + */ + async flushdb(): Promise { + if (!this.isConnected) { + throw new Error("Redis is not connected"); + } + + await this.client.flushdb(); + } + + /** + * Check if Redis is connected + */ + isRedisConnected(): boolean { + return this.isConnected; + } + + /** + * Get Redis client instance for advanced operations + */ + getClient(): Redis { + return this.client; + } + + /** + * Gracefully disconnect from Redis + */ + async disconnect(): Promise { + try { + this.client.disconnect(); + this.isConnected = false; + } catch (error) { + this.logger.error("Error disconnecting from Redis:", error); + } + } +} diff --git a/src/utils/shutdown.utils.ts b/src/utils/shutdown.utils.ts new file mode 100644 index 0000000..51d4ff0 --- /dev/null +++ b/src/utils/shutdown.utils.ts @@ -0,0 +1,32 @@ +import { Server } from "http"; + +import { Worker } from "bullmq"; +import { Server as WSServer } from "socket.io"; + +import { Logger } from "../core/logging/logger"; + +export const gracefulShutdown = async (signal: string, server: Server, wsServer: WSServer, worker: Worker, logger: Logger) => { + try { + logger.warn(`${signal} signal received: closing HTTP server and worker...`); + + logger.warn("close all socket connection.."); + wsServer.disconnectSockets(); + logger.warn("all socket connection disconnected"); + + logger.warn("Closing ws server..."); + wsServer.close(); + logger.warn("ws server closed"); + + server.close(async () => { + logger.warn("HTTP server closed"); + logger.warn("Closing BullMQ worker..."); + await worker.close(true); + logger.warn("BullMQ worker closed"); + + process.exit(0); + }); + } catch (error) { + logger.error("Error during shutdown:", error); + process.exit(1); + } +}; diff --git a/src/utils/sms.service.ts b/src/utils/sms.service.ts new file mode 100644 index 0000000..089db2e --- /dev/null +++ b/src/utils/sms.service.ts @@ -0,0 +1,216 @@ +import axios from "axios"; + +import { priceNumberFormat } from "./helper"; + +export interface IParameterArray { + name: TemplateParams; + value: string; +} +export interface ISmsResponse { + status: number; + message: string; +} +//---------------------------------------------- + +export interface ISmsGetLineResponse extends ISmsResponse { + data: string[]; +} + +export interface ISmsVerifyResponse extends ISmsResponse { + data: { MessageId: number; Cost: number }; +} + +//---------------------------------------------- +export interface ISmsVerifyBody { + Parameters: IParameterArray[]; + Mobile: string; + TemplateId: string; +} + +export type TemplateParams = "name" | "otp" | "order_id" | "price" | "amount"; + +export class SMS { + private static URL = "https://api.sms.ir/v1/send/verify"; + private static API_KEY = process.env.SMS_API_KEY; + // private static SECRET_KEY = process.env.SMS_SECRET; + private static OTP = process.env.SMS_PATTERN_OTP; + private static USER_ORDER_PEND = process.env.SMS_PATTERN_USER_ORDER_PEND; + private static USER_ORDER_CREATE = process.env.SMS_PATTERN_USER_ORDER_CREATE; + private static USER_ORDER_SHIPPED = process.env.SMS_PATTERN_USER_ORDER_SHIPPED; + private static USER_ORDER_RETURNED = process.env.SMS_PATTERN_USER_ORDER_RETURNED; + private static SELLER_ORDER_CREATE = process.env.SMS_PATTERN_SELLER_ORDER_CREATE; + private static SELLER_ORDER_CANCELLED = process.env.SMS_PATTERN_SELLER_ORDER_CANCELLED; + + static async sendSellerOrderCancelledSms(mobile: string, fullName: string, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "name", value: fullName }, + { name: "order_id", value: `${orderId}` }, + ], + Mobile: mobile, + TemplateId: SMS.SELLER_ORDER_CANCELLED, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + //************************************ */ + static async sendOrderShippedSms(mobile: string, name: string, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "name", value: name }, + { name: "order_id", value: `${orderId}` }, + ], + Mobile: mobile, + TemplateId: SMS.USER_ORDER_SHIPPED, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + //************************************ */ + + static async sendOrderReturnedSms(mobile: string, name: string, price: number, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "name", value: name }, + { name: "order_id", value: `${orderId}` }, + { name: "price", value: `${priceNumberFormat(price)}` }, + ], + Mobile: mobile, + TemplateId: SMS.USER_ORDER_RETURNED, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + //************************************ */ + + static async sendSellerOrderCreatedSms(mobile: string, price: number, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "order_id", value: `${orderId}` }, + { name: "amount", value: `${priceNumberFormat(price)}` }, + ], + Mobile: mobile, + TemplateId: SMS.SELLER_ORDER_CREATE, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + + //************************************ */ + static async sendOrderCreatedSms(mobile: string, name: string, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "name", value: name }, + { name: "order_id", value: `${orderId}` }, + ], + Mobile: mobile, + TemplateId: SMS.USER_ORDER_CREATE, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + //************************************ */ + + static async sendOrderPendingSms(mobile: string, name: string, orderId: number) { + const smsData: ISmsVerifyBody = { + Parameters: [ + { name: "name", value: name }, + { name: "order_id", value: `${orderId}` }, + ], + Mobile: mobile, + TemplateId: SMS.USER_ORDER_PEND, + }; + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } + //************************************ */ + + static async sendSmsVerifyCode(mobile: string, otpCode: string) { + const smsData: ISmsVerifyBody = { + Parameters: [{ name: "otp", value: otpCode }], + Mobile: mobile, + TemplateId: SMS.OTP, + }; + + try { + const { data } = await axios.post(SMS.URL, smsData, { + headers: { + "Content-Type": "application/json", + "X-API-KEY": SMS.API_KEY, + }, + }); + return data; + } catch (error) { + console.error("The sms was not sent. Please try again"); + console.error(error); + // throw new InternalError(["The sms was not sent. Please try again"]); + } + } +} diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts new file mode 100644 index 0000000..c7ff837 --- /dev/null +++ b/src/utils/swagger.ts @@ -0,0 +1,228 @@ +import { Application } from "express"; +import basicAuth from "express-basic-auth"; +import { ControllerMetadata } from "inversify-express-utils"; +import swaggerUi from "swagger-ui-express"; + +type CtrlMethodsData = { + key: string; + method: string; + middleware: string[]; + path: string; + target: any; +}; + +type ApiResponses = { + description: string; + status: number; + example: any; +}; + +// eslint-disable-next-line no-unused-vars +function getSwaggerSpec(rootPath: string, _isAdmin: boolean = false) { + const paths: any = {}; + const tags: any[] = []; + const components: any = { + schemas: {}, + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: 'JWT Authorization header using the Bearer scheme. Example: "Authorization: Bearer {token}"', + }, + }, + }; + const controllers: ControllerMetadata[] = Reflect.getMetadata("inversify-express-utils:controller", Reflect); + + controllers.forEach(({ target: controller, path: controllerPath }) => { + const controllerTags = Reflect.getMetadata("api:tags", controller); + + if (controllerTags) { + tags.push(...controllerTags.map((tag: string) => ({ name: tag }))); + } + + const controllerMethods: CtrlMethodsData[] = Reflect.getMetadata("inversify-express-utils:controller-method", controller); + + controllerMethods.forEach((ctrlMethod) => { + // const isAdminRoute = Reflect.getMetadata("api:isAdmin", controller.prototype, ctrlMethod.key) ? true : false; + + // // Filter based on the isAdmin flag + // if (isAdminRoute !== isAdmin) { + // return; + // } + + let path = `${rootPath}${controllerPath}`; + if (ctrlMethod.path.includes(":")) { + const segments = ctrlMethod.path.split("/"); + segments.forEach((segment) => { + if (!segment) return; + if (segment.startsWith(":")) { + path += `/{${segment.slice(1)}}`; + } else { + path += `/${segment}`; + } + }); + } else { + path = `${rootPath}${controllerPath}${ctrlMethod.path}`; + } + const method = ctrlMethod.method; + const summary = Reflect.getMetadata("api:operation:summary", controller.prototype, ctrlMethod.key); + const apiResponses: ApiResponses[] = Reflect.getMetadata("api:responses", controller.prototype, ctrlMethod.key) || []; + const params = Reflect.getMetadata("api:params", controller.prototype, ctrlMethod.key) || []; + const queries = Reflect.getMetadata("api:queries", controller.prototype, ctrlMethod.key) || []; + const bodyDescription = Reflect.getMetadata("api:body", controller.prototype, ctrlMethod.key); + const bodyDto = Reflect.getMetadata("api:body:dto", controller.prototype, ctrlMethod.key); + const fileUpload = Reflect.getMetadata("api:file", controller.prototype, ctrlMethod.key); + const auth = Reflect.getMetadata("api:auth", controller.prototype, ctrlMethod.key); + + if (path && method) { + if (!paths[path]) { + paths[path] = {}; + } + + const requestBody = fileUpload + ? fileUpload.multiple + ? { + description: fileUpload.description, + required: fileUpload.required, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + [fileUpload.description]: { + type: "array", + items: { type: "string", format: "binary" }, // For multiple files + }, + }, + }, + }, + }, + } + : { + description: fileUpload.description, + required: fileUpload.required, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + [fileUpload.description]: { type: "string", format: "binary" }, // For single file + }, + }, + }, + }, + } + : bodyDto + ? { + description: bodyDescription, + required: true, + content: { "application/json": { schema: { $ref: `#/components/schemas/${bodyDto.name}` } } }, + } + : undefined; + + paths[path][method] = { + summary, + parameters: [ + ...params.map((param: any) => ({ + in: "path", + name: param.name, + description: param.description, + required: param.required, + schema: { type: "string" }, + })), + ...queries.map((query: any) => ({ + in: "query", + name: query.name, + description: query.description, + required: query.required, + schema: query.type === "array" ? { type: "array" /*, items: { type: "string" }*/ } : { type: "string" }, + })), + ], + requestBody, + responses: { + ...apiResponses.reduce((acc, curr) => { + return { + ...acc, + [curr.status]: { + description: curr.description, + content: curr.example ? { "application/json": { example: curr.example } } : undefined, + }, + }; + }, {}), + }, + tags: controllerTags, + security: auth ? [{ bearerAuth: [] }] : undefined, + }; + + if (bodyDto) { + components.schemas[bodyDto.name] = generateSchema(bodyDto); + } + } + }); + }); + + return { + openapi: "3.0.0", + info: { + title: "shinan API Documentation", + version: "1.0.0", + description: "shinan API documentation", + contact: { + email: "", + }, + }, + // servers: [ + // { + // url: `http://localhost:4000`, + // description: "Local server", + // }, + // { + // url: `https://api-shinan.run.danakcorp.com/`, + // description: "api server", + // }, + // ], + paths, + tags, + components, + }; +} + +function generateSchema(dto: any) { + const schema: Record = { type: "object", properties: {}, required: [] }; + const instance = new dto(); + + for (const key in instance) { + const options = Reflect.getMetadata("api:property", dto.prototype, key); + + if (options) { + schema.properties[key] = { + type: options.type, + description: options.description, + example: options.example, + }; + + if (options.required) { + schema.required.push(key); + } + } + } + + if (schema.required.length === 0) { + delete schema.required; + } + + return schema; +} + +export function setupSwagger(app: Application, rootPath: string = "") { + const generalSwaggerSpec = getSwaggerSpec(rootPath, false); // For general users and sellers + // const adminSwaggerSpec = getSwaggerSpec(rootPath, true); // For admin users + + // Setup public Swagger docs + app.use("/api-docs", basicAuth({ users: { admin: "admin" }, challenge: true })); + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(generalSwaggerSpec)); + + // Setup admin Swagger docs + // app.use("/api-docs/admin", swaggerUi.serve, swaggerUi.setup(adminSwaggerSpec)); +} diff --git a/src/utils/time.service.ts b/src/utils/time.service.ts new file mode 100644 index 0000000..1a38712 --- /dev/null +++ b/src/utils/time.service.ts @@ -0,0 +1,90 @@ +import moment from "jalali-moment"; +class TimeService { + /** + * Get the current date in Gregorian format + */ + static getCurrentGregorianDate(): Date { + return new Date(); + } + /** + * Add time to a given date + * @param date - A JavaScript Date object or string (ISO) + * @param amount - Amount of time to add + * @param unit - Unit of time (e.g., "days", "hours", "minutes") + */ + static addTime(date: Date | string, amount: number, unit: moment.unitOfTime.DurationConstructor): string { + return moment(date).add(amount, unit).toISOString(); + } + /** + * + * @param date - A data Object or string or timestamp + * @returns + */ + static PersianMonthName(date: string | Date | number) { + return new Date(date).toLocaleString("fa-IR", { month: "long" }); + } + + /** + * Subtract time from a given date + * @param date - A JavaScript Date object or string (ISO) + * @param amount - Amount of time to subtract + * @param unit - Unit of time (e.g., "days", "hours", "minutes") + */ + static subtractTime(date: Date | string, amount: number, unit: moment.unitOfTime.DurationConstructor): string { + return moment(date).subtract(amount, unit).toISOString(); + } + + /** + * Get the current date in Persian (Jalali) format + * @param fullTime - Whether to include time (default: false) + * @param format - Optional, format to return the date in (default: "jYYYY/jMM/jDD") + */ + static getCurrentPersianDate(fullTime: boolean = false, format: string = "jYYYY/jMM/jDD"): string { + const currentDate = moment().locale("fa"); + return fullTime ? currentDate.format(`${format} HH:mm:ss`) : currentDate.format(format); + } + + /** + * Convert a Persian (Jalali) date to Gregorian + * @param persianDate - Date in "jYYYY/jMM/jDD" format + * @param format - Format for the output (default: "YYYY/MM/DD") + */ + static convertPersianToGregorian(persianDate: string, format: string = "YYYY-MM-DD"): string { + return moment(persianDate, "jYYYY/jMM/jDD").locale("en").format(format) + "Z"; + } + + /** + * Convert a Gregorian date to Persian (Jalali) + * @param gregorianDate - A JavaScript Date object or string (ISO) + * @param fullTime - Whether to include time (default: false) + * @param format - Format for the output (default: "jYYYY/jMM/jDD") + */ + static convertGregorianToPersian(gregorianDate: Date | string, fullTime: boolean = false, format: string = "jYYYY/jMM/jDD"): string { + const momentDate = moment(gregorianDate, "YYYY-MM-DD").locale("fa"); + return fullTime ? momentDate.format(`${format} HH:mm:ss`) : momentDate.format(format); + } + + /** + * Check if a Persian date is before the current date (i.e., has expired) + * @param persianDate - Date in "jYYYY/jMM/jDD" format + */ + static isPersianDateExpired(persianDate: string): boolean { + const currentDate = moment(); + + const expirationDate = moment(persianDate, "jYYYY/jMM/jDD").locale("fa"); + + return expirationDate.isBefore(currentDate); + } +} +export { TimeService }; + +// const utcDate = new Date().toISOString(); +// const iranTime = new Date(utcDate).toLocaleString('en-IR', { timeZone: 'Asia/Seoul' }); +// console.log(iranTime); +// new Date().toLocaleString() + +// console.log( +// TimeService.convertGregorianToPersian( +// TimeService.addTime(new Date(TimeService.convertPersianToGregorian(TimeService.getCurrentPersianDate())), 2, "year"), +// ), +// ); diff --git a/src/utils/translate.utils.ts b/src/utils/translate.utils.ts new file mode 100644 index 0000000..fb6f0d9 --- /dev/null +++ b/src/utils/translate.utils.ts @@ -0,0 +1,5 @@ +import { Translate } from "../modules/wallet/constant"; + +export function translate(word: keyof typeof Translate): string { + return Translate[word]; +} diff --git a/src/utils/upload.service.ts b/src/utils/upload.service.ts new file mode 100644 index 0000000..142fb02 --- /dev/null +++ b/src/utils/upload.service.ts @@ -0,0 +1,105 @@ +import { randomBytes } from "crypto"; +import { extname } from "path"; + +import { S3Client } from "@aws-sdk/client-s3"; +import { RequestHandler } from "express"; +import multer, { Multer, MulterError, StorageEngine } from "multer"; +import multerS3, { AUTO_CONTENT_TYPE } from "multer-s3"; + +import { UploadError } from "../core/app/app.errors"; + +class UploadService { + private static instance: UploadService; + private constructor() {} + + static get(): UploadService { + if (!UploadService.instance) { + UploadService.instance = new UploadService(); + } + return UploadService.instance; + } + private S3Config = { + credentials: { + accessKeyId: process.env.BUCKET_ACCESS_KEY, + secretAccessKey: process.env.BUCKET_SECRET_KEY, + }, + endpoint: process.env.BUCKET_URL, + region: "default", + }; + + private fileOption = { + size: 6 * 1024 * 1024, // 1 MB + formats: { + "image/jpeg": "jpg", + "image/webp": "webp", + "image/png": "png", + "image/gif": "gif", + "application/pdf": "pdf", + "video/mp4": "mp4", + "audio/mpeg": "mp3", + "audio/wav": "wav", + "audio/ogg": "ogg", + "video/mpeg": "mpeg", + } as { [key: string]: string }, + }; + + private S3Storage = (prefix: string): StorageEngine => + multerS3({ + s3: new S3Client(this.S3Config), + bucket: process.env.BUCKET_NAME, + acl: "public-read", + contentType: AUTO_CONTENT_TYPE, + metadata: (_req, file, cb) => { + cb(null, { fieldName: file.fieldname }); + }, + key: (_req, file, cb) => { + const fileName = `${randomBytes(24).toString("hex")}${extname(file.originalname)}`; + const fullPath = `${prefix}/${fileName}`; // full path with the prefix + cb(null, fullPath); + }, + }); + + private upload = (prefix: string): Multer => + multer({ + storage: this.S3Storage(prefix), + limits: { fileSize: this.fileOption.size }, + fileFilter: (_req, file, cb) => { + if (!this.fileOption.formats[file.mimetype]) { + return cb(new UploadError(file.fieldname, ["FILE_TYPE_NOT_ALLOWED"])); + } + cb(null, true); + }, + }); + + // middleware function to handle single file uploads + public single(fieldName: string, prefix: string = "default"): RequestHandler { + return (req, res, next) => { + this.upload(prefix).single(fieldName)(req, res, async (err) => { + if (err instanceof MulterError) { + return next(new UploadError(fieldName, [err.code])); + } else if (err) { + return next(err); + } + next(); + }); + }; + } + + // middleware function to handle multiple file uploads + public multiple(fieldName: string, maxCount: number, prefix: string = "default"): RequestHandler { + return (req, res, next) => { + this.upload(prefix).array(fieldName, maxCount)(req, res, async (err) => { + if (err instanceof MulterError) { + return next(new UploadError(fieldName, [err.code])); + } else if (err) { + return next(err); + } + next(); + }); + }; + } +} + +const instance = UploadService.get(); + +export { instance as UploadService }; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..1e34b46 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "incremental": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..09ea69a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "lib": ["ES2023"], + "moduleResolution": "NodeNext", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noEmitOnError": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "removeComments": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "strictPropertyInitialization": false, + "allowSyntheticDefaultImports": true, + "strictNullChecks": true, + "skipLibCheck": true + }, + "ts-node": { + "files": true + } +}